From 6c4a8a3cf56a76e9da8e5ee19367368b733344eb Mon Sep 17 00:00:00 2001 From: Loki Date: Fri, 14 Aug 2026 21:37:51 +0000 Subject: [PATCH] Supervision du moteur sans systemd (conteneur Docker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau sys_service_container.go : supervision par fichier PID portée du mode utilisateur macOS (Setsid, SIGTERM sur le groupe puis SIGKILL, log fichier). sys_service_linux.go bascule automatiquement quand /run/systemd/system est absent ou que LOKI_CONTAINER=1 ; une install systemd classique est inchangée. Le tunnel ajean.link suit la même logique (uiServiceCtl / uiServiceActive). Sans ce repli, changer de modèle depuis l'UI (serviceAction restart) échouait sur un systemctl absent — le conteneur était inutilisable. Testé sans systemd : start / status / restart / stop, PID suivi, processus enfant arrêté avec le groupe. build linux+darwin+windows OK, go vet + go test verts. --- internal/loki/backend_buildfilter_test.go | 2 +- internal/loki/backend_config_quote_test.go | 6 +- internal/loki/node_server.go | 2 +- internal/loki/node_ws_test.go | 2 +- internal/loki/relay_link.go | 6 +- internal/loki/sys_service_container.go | 204 +++++++++++++++++++++ internal/loki/sys_service_darwin.go | 3 + internal/loki/sys_service_linux.go | 15 ++ internal/loki/sys_service_windows.go | 3 + internal/nodeclient/client.go | 2 +- 10 files changed, 236 insertions(+), 9 deletions(-) create mode 100644 internal/loki/sys_service_container.go diff --git a/internal/loki/backend_buildfilter_test.go b/internal/loki/backend_buildfilter_test.go index e1ac352..ff049c9 100644 --- a/internal/loki/backend_buildfilter_test.go +++ b/internal/loki/backend_buildfilter_test.go @@ -12,7 +12,7 @@ func TestCompiledFile(t *testing.T) { `[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu", // La ligne de commande nvcc géante ne doit PAS être prise pour un fichier. ` C:\...\nvcc.exe -x cu ... -o ggml-cuda.dir\Release\acc.obj "C:\...\acc.cu"`: "", - `Building Custom Rule C:/ProgramData/loki/...`: "", + `Building Custom Rule C:/ProgramData/loki/...`: "", `-- UI: running npm install`: "", } for in, want := range cases { diff --git a/internal/loki/backend_config_quote_test.go b/internal/loki/backend_config_quote_test.go index 00840ab..a34a0e0 100644 --- a/internal/loki/backend_config_quote_test.go +++ b/internal/loki/backend_config_quote_test.go @@ -11,9 +11,9 @@ import ( func TestParseEnvKeepsInnerQuotes(t *testing.T) { cases := map[string]string{ `EXTRA_ARGS=--jinja --chat-template-file "/etc/loki/tpl.jinja"`: `--jinja --chat-template-file "/etc/loki/tpl.jinja"`, - `EXTRA_ARGS="--jinja --flash-attn"`: `--jinja --flash-attn`, - `MODEL='/mnt/d/x.gguf'`: `/mnt/d/x.gguf`, - `MODEL=/mnt/d/x.gguf`: `/mnt/d/x.gguf`, + `EXTRA_ARGS="--jinja --flash-attn"`: `--jinja --flash-attn`, + `MODEL='/mnt/d/x.gguf'`: `/mnt/d/x.gguf`, + `MODEL=/mnt/d/x.gguf`: `/mnt/d/x.gguf`, } for line, want := range cases { m := parseEnv(line) diff --git a/internal/loki/node_server.go b/internal/loki/node_server.go index d75f56f..9b5739d 100644 --- a/internal/loki/node_server.go +++ b/internal/loki/node_server.go @@ -16,9 +16,9 @@ import ( "sync" "time" + "github.com/R0m1k3/Loki/internal/nodewire" "github.com/coder/websocket" "github.com/coder/websocket/wsjson" - "github.com/R0m1k3/Loki/internal/nodewire" ) const ( diff --git a/internal/loki/node_ws_test.go b/internal/loki/node_ws_test.go index 53311dd..60183c1 100644 --- a/internal/loki/node_ws_test.go +++ b/internal/loki/node_ws_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" + "github.com/R0m1k3/Loki/internal/nodewire" "github.com/coder/websocket" "github.com/coder/websocket/wsjson" - "github.com/R0m1k3/Loki/internal/nodewire" ) // TestNodeE2E exerce tout le chemin : enrôlement SCELLÉ (le sceau du client est diff --git a/internal/loki/relay_link.go b/internal/loki/relay_link.go index d336cd7..9df054b 100644 --- a/internal/loki/relay_link.go +++ b/internal/loki/relay_link.go @@ -322,7 +322,9 @@ func uiLogPath() string { return filepath.Join(LokiHome(), uiUnitName+".log") } // « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne // composait jamais le tunnel. func uiServiceCtl(action string) error { - if runtime.GOOS != "linux" { + // Linux sans systemd (conteneur Docker) : même traitement que macOS/Windows — + // le process `loki web` possède le tunnel (appOwnsLink), sinon worker PID. + if runtime.GOOS != "linux" || !systemdAvailable() { // Dans l'app de bureau, le tunnel tourne DANS ce process (conversation // unique) : on ne lance surtout pas un worker séparé. if appOwnsLink { @@ -364,7 +366,7 @@ func uiServiceCtl(action string) error { // uiServiceActive indique si le worker de lien tourne (unité systemd sous // Linux, processus suivi par fichier PID ailleurs). func uiServiceActive() bool { - if runtime.GOOS != "linux" { + if runtime.GOOS != "linux" || !systemdAvailable() { if appOwnsLink { return appLinkRunning() } diff --git a/internal/loki/sys_service_container.go b/internal/loki/sys_service_container.go new file mode 100644 index 0000000..1288ff3 --- /dev/null +++ b/internal/loki/sys_service_container.go @@ -0,0 +1,204 @@ +//go:build linux + +package loki + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// sys_service_container.go — supervision du moteur SANS systemd (conteneur +// Docker, distribution sans systemd, WSL…). Même approche que le mode +// utilisateur de macOS et que Windows : `loki serve` est lancé détaché, son PID +// va dans LOKI_HOME/.pid et sa sortie dans LOKI_HOME/.log. Sans ce +// repli, chaque changement de modèle depuis l'UI (serviceAction("restart")) +// échouerait sur un systemctl absent — le conteneur serait inutilisable. +// +// La bascule est automatique : sys_service_linux.go route ici quand +// /run/systemd/system n'existe pas, ou quand LOKI_CONTAINER=1 force le mode. + +// systemdAvailable dit si systemd pilote cette machine. /run/systemd/system +// n'existe que lorsque systemd est PID 1 (convention documentée par sd_booted). +func systemdAvailable() bool { + if os.Getenv("LOKI_CONTAINER") == "1" { + return false + } + st, err := os.Stat("/run/systemd/system") + return err == nil && st.IsDir() +} + +func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid") } +func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") } + +func userSvcAction(action string) error { + switch action { + case "start": + return userSvcStart() + case "stop": + return userSvcStop(true) + case "restart": + _ = userSvcStop(false) + time.Sleep(500 * time.Millisecond) + return userSvcStart() + case "status": + pid := readServicePID() + if pid > 0 && processAlive(pid) { + fmt.Printf("%s %s: actif (PID %d)\n", green("[ok]"), serviceName(), pid) + } else { + fmt.Printf("%s %s: arrêté\n", yellow("[info]"), serviceName()) + } + fmt.Printf(" logs : %s\n", logFilePath()) + return nil + case "enable", "disable": + fmt.Printf("%s '%s' est sans objet ici (pas de systemd) : en conteneur, le démarrage\n", yellow("[info]"), action) + fmt.Printf(" est géré par la politique de redémarrage Docker (restart: unless-stopped).\n") + return nil + } + return fmt.Errorf("action inconnue: %s", action) +} + +func userSvcStart() error { + if pid := readServicePID(); pid > 0 && processAlive(pid) { + fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid) + return nil + } + self, err := os.Executable() + if err != nil { + return err + } + if err := os.MkdirAll(LokiHome(), 0o755); err != nil { + return err + } + logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("ouverture du log %s: %w", logFilePath(), err) + } + defer logf.Close() + + cmd := exec.Command(self, "serve") + cmd.Stdout, cmd.Stderr = logf, logf + // Même répertoire de travail que sous systemd : les chemins relatifs de + // config.env (MODEL=…gguf) se résolvent depuis LOKI_HOME. + cmd.Dir = LokiHome() + // Setsid : l'enfant devient chef de groupe — il survit à la fin de ce + // process, et un signal au groupe (-pid) arrête aussi llama-server. + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return fmt.Errorf("démarrage de 'loki serve': %w", err) + } + pid := cmd.Process.Pid + if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil { + return fmt.Errorf("écriture du PID: %w", err) + } + _ = cmd.Process.Release() + return userCheckStarted(pid) +} + +func userCheckStarted(pid int) error { + time.Sleep(2 * time.Second) + if processAlive(pid) { + fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid) + fmt.Printf(" logs: %s (loki logs pour suivre)\n", dim(logFilePath())) + return nil + } + fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName()) + fmt.Println("------------------------------------------------") + fmt.Print(tailFile(logFilePath(), 20)) + fmt.Println("------------------------------------------------") + fmt.Printf("→ loki logs pour plus de détails\n→ loki edit pour corriger config.env\n") + _ = os.Remove(pidFilePath()) + return fmt.Errorf("service %s non démarré", serviceName()) +} + +func userSvcStop(verbose bool) error { + pid := readServicePID() + if pid <= 0 || !processAlive(pid) { + _ = 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) + } + _ = os.Remove(pidFilePath()) + if verbose { + fmt.Println(green("[ok]") + " arrêté") + } + return nil +} + +// userServiceLogs affiche la fin du log puis suit les ajouts (tail -f minimal, +// sans dépendre d'un binaire `tail` présent dans l'image). +func userServiceLogs() error { + path := logFilePath() + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("aucun log à %s (le service a-t-il déjà démarré ?): %w", path, err) + } + defer f.Close() + fmt.Print(tailFile(path, 80)) + if _, err := f.Seek(0, io.SeekEnd); err != nil { + return err + } + buf := make([]byte, 4096) + for { + n, err := f.Read(buf) + if n > 0 { + os.Stdout.Write(buf[:n]) + } + if err == io.EOF { + time.Sleep(500 * time.Millisecond) + continue + } + if err != nil { + return err + } + } +} + +func readServicePID() int { + b, err := os.ReadFile(pidFilePath()) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(b))) + return pid +} + +// processAlive : le signal 0 ne tue rien, il teste juste l'existence du process. +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + return syscall.Kill(pid, 0) == nil +} + +// tailFile renvoie les n dernières lignes d'un fichier (best-effort). +func tailFile(path string, n int) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") + "\n" +} diff --git a/internal/loki/sys_service_darwin.go b/internal/loki/sys_service_darwin.go index 4ee0fbb..47c11f5 100644 --- a/internal/loki/sys_service_darwin.go +++ b/internal/loki/sys_service_darwin.go @@ -152,6 +152,9 @@ func serviceIsActive() bool { func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid") } func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") } +// systemdAvailable : jamais de systemd sur macOS (consulté par relay_link.go). +func systemdAvailable() bool { return false } + func userSvcAction(action string) error { switch action { case "start": diff --git a/internal/loki/sys_service_linux.go b/internal/loki/sys_service_linux.go index 25a2da9..11db409 100644 --- a/internal/loki/sys_service_linux.go +++ b/internal/loki/sys_service_linux.go @@ -13,7 +13,12 @@ import ( // serviceAction wraps `systemctl ` with passwordless sudo where // it makes sense, and prints a follow-up status check after start/restart. +// Sans systemd (conteneur Docker, LOKI_CONTAINER=1), on bascule sur la +// supervision par fichier PID (sys_service_container.go). func serviceAction(action string) error { + if !systemdAvailable() { + return userSvcAction(action) + } svc := serviceName() needsRoot := action == "start" || action == "stop" || action == "restart" || action == "enable" || action == "disable" args := []string{} @@ -103,6 +108,9 @@ func checkStarted(svc string) error { } func serviceLogs() error { + if !systemdAvailable() { + return userServiceLogs() + } cmd := exec.Command("journalctl", "-u", serviceName(), "-n", "80", "-f") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -111,6 +119,10 @@ func serviceLogs() error { // serviceIsActive reports whether the systemd unit is currently running. func serviceIsActive() bool { + if !systemdAvailable() { + pid := readServicePID() + return pid > 0 && processAlive(pid) + } out, _ := exec.Command("systemctl", "is-active", serviceName()).Output() return strings.TrimSpace(string(out)) == "active" } @@ -118,6 +130,9 @@ func serviceIsActive() bool { // serviceLogTail renvoie les n dernières lignes du journal du service (pour // l'UI web). Linux : journalctl. func serviceLogTail(n int) string { + if !systemdAvailable() { + return tailFile(logFilePath(), n) + } out, err := exec.Command("journalctl", "-u", serviceName(), "-n", strconv.Itoa(n), "--no-pager").CombinedOutput() if err != nil && len(out) == 0 { return "journalctl indisponible : " + err.Error() diff --git a/internal/loki/sys_service_windows.go b/internal/loki/sys_service_windows.go index e8b1502..12906c2 100644 --- a/internal/loki/sys_service_windows.go +++ b/internal/loki/sys_service_windows.go @@ -29,6 +29,9 @@ const ( func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid") } func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") } +// systemdAvailable : jamais de systemd sous Windows (consulté par relay_link.go). +func systemdAvailable() bool { return false } + func serviceAction(action string) error { switch action { case "start": diff --git a/internal/nodeclient/client.go b/internal/nodeclient/client.go index 69b8d18..1d80a5b 100644 --- a/internal/nodeclient/client.go +++ b/internal/nodeclient/client.go @@ -26,9 +26,9 @@ import ( "strings" "time" + "github.com/R0m1k3/Loki/internal/nodewire" "github.com/coder/websocket" "github.com/coder/websocket/wsjson" - "github.com/R0m1k3/Loki/internal/nodewire" ) const (