diff --git a/README.fr.md b/README.fr.md index a126ef4..de5b48b 100644 --- a/README.fr.md +++ b/README.fr.md @@ -184,7 +184,7 @@ La section **Moteur** installe et met à jour llama.cpp en trois variantes, choi - **compilé** : construit pour cette machine (CUDA avec la bonne architecture pour chaque GPU, ROCm, Metal, Vulkan ou CPU) ; - **personnalisé** : n'importe quel fork, depuis une URL Git, pour les modèles qui exigent un moteur particulier. -**Moteur tiers (Linux).** Un preset API externe peut nommer une unité systemd avec `EXTERNAL_SERVICE=ajean-`. AJEAN la démarre quand on bascule sur ce preset, l'arrête quand on en change, et attend que les GPU soient libérés avant de charger le modèle suivant. Donnez à l'unité `Conflicts=ajean-engine.service`. +**Moteur tiers (Linux & Windows).** Un preset API externe peut nommer un service avec `EXTERNAL_SERVICE=ajean-`. AJEAN le démarre quand on bascule sur ce preset, l'arrête quand on en change, et attend que les GPU soient libérés avant de charger le modèle suivant. Sous Linux le service est une unité systemd (donnez-lui `Conflicts=ajean-engine.service`) ; sous Windows c'est un processus local défini par `$AJEAN_HOME\services\ajean-.json` — `command`, `args`, `cwd` et `env` optionnel, lancé en fond avec son journal dans `$AJEAN_HOME`. --- diff --git a/README.md b/README.md index b05a1ac..09f0799 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The **Engine** section installs and updates llama.cpp in three flavors, chosen p - **compiled**: built for this machine (CUDA with the right compute capability for each GPU, ROCm, Metal, Vulkan or CPU); - **custom**: any fork, from a Git URL, for models that need a special engine. -**Third-party engine (Linux).** An External API preset can name a systemd unit with `EXTERNAL_SERVICE=ajean-`. AJEAN starts it when you switch to the preset, stops it when you switch away, and waits for the GPUs to be released before loading the next model. Give the unit `Conflicts=ajean-engine.service`. +**Third-party engine (Linux & Windows).** An External API preset can name a service with `EXTERNAL_SERVICE=ajean-`. AJEAN starts it when you switch to the preset, stops it when you switch away, and waits for the GPUs to be released before loading the next model. On Linux the service is a systemd unit (give it `Conflicts=ajean-engine.service`); on Windows it is a local process defined by `$AJEAN_HOME\services\ajean-.json` — `command`, `args`, `cwd` and optional `env`, launched detached with its log in `$AJEAN_HOME`. --- diff --git a/internal/ajean/backend_extservice.go b/internal/ajean/backend_extservice.go index 67f10eb..b325340 100644 --- a/internal/ajean/backend_extservice.go +++ b/internal/ajean/backend_extservice.go @@ -10,10 +10,17 @@ import ( // backend_extservice.go — un preset externe peut nommer le service système qui // sert son API (clé EXTERNAL_SERVICE, ex. « ajean-moe » : un moteur tiers -// comme un moteur MoE spécialisé, lancé par sa propre unité systemd). ajean le démarre quand on -// bascule sur ce preset et l'arrête quand on en repart, pour qu'un seul moteur -// occupe les GPU à la fois. L'unité déclare en plus Conflicts=ajean-engine : -// systemd arrête l'un quand l'autre démarre, quel que soit l'ordre des appels. +// comme un moteur MoE spécialisé). ajean le démarre quand on bascule sur ce +// preset et l'arrête quand on en repart, pour qu'un seul moteur occupe les +// GPU à la fois. +// +// Sous Linux, le service est une unité systemd (l'unité déclare en plus +// Conflicts=ajean-engine : systemd arrête l'un quand l'autre démarre, quel +// que soit l'ordre des appels). Sous Windows, c'est un processus local défini +// par $AJEAN_HOME\services\ajean-.json (voir +// sys_service_windows_extservice.go) — l'exclusivité des GPU y est obtenue par +// l'ordre des appels de SwitchToPreset, qui arrête toujours un moteur avant de +// démarrer l'autre. const extKeyService = "EXTERNAL_SERVICE" diff --git a/internal/ajean/sys_service_windows.go b/internal/ajean/sys_service_windows.go index 91089f3..6829ef4 100644 --- a/internal/ajean/sys_service_windows.go +++ b/internal/ajean/sys_service_windows.go @@ -317,17 +317,6 @@ func tailFile(path string, n int) string { // l'UI web). func serviceLogTail(n int) string { return tailFile(logFilePath(), n) } -// unitAction : les services externes de preset (EXTERNAL_SERVICE) ne sont gérés -// que sous Linux (systemd). -func unitAction(unit, action string) error { - return fmt.Errorf("EXTERNAL_SERVICE n'est pris en charge que sous Linux") -} - -// waitGPUsReleased : voir la version Linux (EXTERNAL_SERVICE n'y existe pas). -func waitGPUsReleased(pids []int, max time.Duration) {} - -// unitPIDs : voir la version Linux. -func unitPIDs(unit string) []int { return nil } - -// unitActiveState : pas de services externes hors Linux. -func unitActiveState(unit string) string { return "" } +// Les services externes de preset (EXTERNAL_SERVICE) vivent dans +// sys_service_windows_extservice.go : unitAction / waitGPUsReleased / unitPIDs / +// unitActiveState, l'équivalent Windows des unités systemd. diff --git a/internal/ajean/sys_service_windows_extservice.go b/internal/ajean/sys_service_windows_extservice.go new file mode 100644 index 0000000..fe48c06 --- /dev/null +++ b/internal/ajean/sys_service_windows_extservice.go @@ -0,0 +1,275 @@ +//go:build windows + +package ajean + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// sys_service_windows_extservice.go — EXTERNAL_SERVICE sous Windows, +// l'équivalent du pilote systemd de sys_service_linux.go (unitAction, +// waitGPUsReleased, unitPIDs, unitActiveState). Un preset externe peut nommer +// un service local (« ajean- ») ; AJEAN le démarre à la bascule sur le +// preset, l'arrête quand on en repart, et attend la libération de la VRAM avant +// de charger le modèle suivant — exactement le contrat des unités systemd, sans +// systemd. +// +// Définition : l'équivalent du fichier d'unité vit dans +// $AJEAN_HOME\services\ajean-.json — le dossier est créé au premier +// service. JSON (pas de .bat) : la commande, ses arguments, le cwd et les +// variables d'environnement sont portés sans ambiguïté de quoting : +// +// { +// "command": "I:\\Strata\\.venv\\Scripts\\python.exe", +// "args": ["I:\\Strata\\serve\\server.py", "--engine", "strata", "--port", "8080"], +// "cwd": "I:\\Strata", +// "env": { "PYTHONUTF8": "1" } +// } +// +// Le processus est lancé détaché (comme `ajean serve`), suivi par un fichier +// PID, journalisé dans $AJEAN_HOME, arrêté par taskkill /T /F : mêmes +// primitives que le superviseur local (svcStart/svcStop), sans droits admin. + +// extServiceDef porte la définition d'un service externe sous Windows. +type extServiceDef struct { + Command string `json:"command"` + Args []string `json:"args"` + Cwd string `json:"cwd"` + Env map[string]string `json:"env"` +} + +// servicesDir : le dossier des définitions de services externes +// ($AJEAN_HOME\services), créé à la volée au premier service. Il vit ici (et +// pas dans run.go) car seul ce fichier — tagué windows — l'utilise : une +// fonction partagée inutilisée hors Windows ferait un U1000 de staticcheck sur +// la CI ubuntu (le projet est à zéro avertissement). +func servicesDir() string { return filepath.Join(AjeanHome(), "services") } + +// extMu sérialise les démarrages : un double start parallèle lançait deux +// moteurs (même écueil que svcMu pour le moteur local, issue #114). +var extMu sync.Mutex + +// extUnitPath : la définition du service, $AJEAN_HOME\services\ajean-.json. +func extUnitPath(unit string) string { + return filepath.Join(servicesDir(), unit+".json") +} + +// extPidPath / extLogPath : suivi et journal, à côté de ceux du moteur local +// (ajean-engine.pid / ajean-engine.log), un fichier par unité. +func extPidPath(unit string) string { return filepath.Join(AjeanHome(), unit+".pid") } +func extLogPath(unit string) string { return filepath.Join(AjeanHome(), unit+".log") } + +// loadExtServiceDef lit et valide la définition. Un chemin relatif est résolu +// depuis le dossier du fichier de définition (comme systemd résout ExecStart +// relatif) — un service reste portable si tout $AJEAN_HOME bouge. +func loadExtServiceDef(unit string) (*extServiceDef, error) { + p := extUnitPath(unit) + b, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("définition introuvable : %s — créez ce fichier (command, args, cwd)", p) + } + return nil, err + } + var def extServiceDef + if err := json.Unmarshal(b, &def); err != nil { + return nil, fmt.Errorf("définition illisible (%s) : %w", p, err) + } + def.Command = strings.TrimSpace(def.Command) + if def.Command == "" { + return nil, fmt.Errorf("définition %s : champ \"command\" manquant", p) + } + if !filepath.IsAbs(def.Command) { + def.Command = filepath.Join(filepath.Dir(p), def.Command) + } + if def.Cwd != "" && !filepath.IsAbs(def.Cwd) { + def.Cwd = filepath.Join(filepath.Dir(p), def.Cwd) + } + return &def, nil +} + +// extUnitPID : le PID du service, 0 s'il ne tourne pas (fichier absent ou +// process mort). Le PID ne sert qu'au kill (taskkill /T) et au suivi : le +// status vient de processAlive, pas du fichier. +func extUnitPID(unit string) int { + b, err := os.ReadFile(extPidPath(unit)) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(b))) + if pid <= 0 || !processAlive(pid) { + return 0 + } + return pid +} + +// unitAction : start/stop d'une unité externe. Même contrat que la version +// Linux — syncExternalService (backend_extservice.go) ne connaît que ces deux +// actions. +func unitAction(unit, action string) error { + switch action { + case "start": + return extUnitStart(unit) + case "stop": + return extUnitStop(unit) + } + return fmt.Errorf("action inconnue : %s", action) +} + +func extUnitStart(unit string) error { + extMu.Lock() + defer extMu.Unlock() + if pid := extUnitPID(unit); pid > 0 { + return nil // idempotent : syncExternalService relance start à chaque bascule + } + def, err := loadExtServiceDef(unit) + if err != nil { + return err + } + // Journal : best-effort, comme openEngineLog — un log inaccessible ne doit + // PAS faire échouer le démarrage (issue #88). + var logf *os.File + if f, err := os.OpenFile(extLogPath(unit), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err == nil { + logf = f + } + defer func() { + if logf != nil { + logf.Close() + } + }() + + cmd := exec.Command(def.Command, def.Args...) + if logf != nil { + cmd.Stdout = logf + cmd.Stderr = logf + } + if def.Cwd != "" { + cmd.Dir = def.Cwd + } + // Environnement : celui du parent, complété par la définition. + env := os.Environ() + for k, v := range def.Env { + env = append(env, k+"="+v) + } + cmd.Env = env + // Détaché, sans console : le service survit à la fermeture de l'UI et ne + // doit jamais faire clignoter de fenêtre noire (même attr que svcStart). + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow, + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("démarrage de %s : %w", unit, err) + } + pid := cmd.Process.Pid + // PID en best-effort : un échec d'écriture dégrade stop/status mais ne + // casse pas un moteur déjà lancé. + if err := os.WriteFile(extPidPath(unit), []byte(strconv.Itoa(pid)), 0o644); err != nil { + fmt.Printf("%s PID de %s non enregistré (%v)\n", yellow("[avert]"), unit, err) + } + _ = cmd.Process.Release() + + // checkStarted (2 s) du moteur local : trop court pour un gros modèle + // (Strata charge en une minute ou plus), trop long pour une vraie erreur. + // Compromis : 2 s, comme svcStart — externalServiceReady est le juge de + // paix (unité active + API qui répond), il distingue « chargement » + // (silence) de « échec » (unité morte). + time.Sleep(2 * time.Second) + if !processAlive(pid) { + fmt.Printf("%s %s : le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), unit) + fmt.Println("------------------------------------------------") + fmt.Print(tailFile(extLogPath(unit), 20)) + fmt.Println("------------------------------------------------") + _ = os.Remove(extPidPath(unit)) + return fmt.Errorf("service %s non démarré", unit) + } + return nil +} + +func extUnitStop(unit string) error { + pid := extUnitPID(unit) + if pid == 0 { + _ = os.Remove(extPidPath(unit)) + return nil // rien à arrêter : idempotent + } + // taskkill /T : le process ET ses enfants (python → moteur → workers). + cmd := hideCmd(exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("arrêt de %s (PID %d): %w\n%s", unit, pid, err, string(out)) + } + // taskkill /F rend la main AVANT la destruction effective : la VRAM reste + // verrouillée par le pilote un instant. waitGPUsReleased (appelé juste + // après par syncExternalService) couvre l'intervalle ; on ne bloque pas + // ici, comme svcStop. + _ = os.Remove(extPidPath(unit)) + return nil +} + +// unitActiveState : ActiveState équivalent — « active » si le process tourne, +// « failed » s'il s'est arrêté (PID connu mais mort), « inactive » sinon. +func unitActiveState(unit string) string { + p := extPidPath(unit) + b, err := os.ReadFile(p) + if err != nil { + return "inactive" + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(b))) + if pid > 0 { + if processAlive(pid) { + return "active" + } + return "failed" + } + return "inactive" +} + +// unitPIDs : le PID du service (nil s'il ne tourne pas). syncExternalService +// les relève AVANT le stop pour que waitGPUsReleased sache quoi attendre. +func unitPIDs(unit string) []int { + pid := extUnitPID(unit) + if pid == 0 { + return nil + } + return []int{pid} +} + +// waitGPUsReleased : porté de sys_service_linux.go — nvidia-smi existe sous +// Windows, même interrogation. On ne regarde QUE les pids donnés : un autre +// programme GPU permanent ne doit pas faire attendre le délai entier. +func waitGPUsReleased(pids []int, max time.Duration) { + if _, err := exec.LookPath("nvidia-smi"); err != nil { + return + } + watch := map[string]bool{} + for _, p := range pids { + watch[strconv.Itoa(p)] = true + } + deadline := time.Now().Add(max) + for time.Now().Before(deadline) { + out, err := exec.Command("nvidia-smi", "--query-compute-apps=pid", "--format=csv,noheader").Output() + if err != nil { + return + } + busy := false + for _, l := range strings.Split(string(out), "\n") { + l = strings.TrimSpace(l) + if l != "" && (len(watch) == 0 || watch[l]) { + busy = true + break + } + } + if !busy { + return + } + time.Sleep(500 * time.Millisecond) + } +} diff --git a/internal/ajean/sys_service_windows_extservice_test.go b/internal/ajean/sys_service_windows_extservice_test.go new file mode 100644 index 0000000..1b41213 --- /dev/null +++ b/internal/ajean/sys_service_windows_extservice_test.go @@ -0,0 +1,176 @@ +//go:build windows + +package ajean + +import ( + "os" + "path/filepath" + "strconv" + "testing" +) + +// Tests Windows des services externes (EXTERNAL_SERVICE) : la définition +// (services/ajean-.json), le suivi PID, et l'exclusivité GPU obtenue par +// l'ordre des appels — l'équivalent des garanties systemd sous Linux. + +func TestExtServiceDefParsing(t *testing.T) { + home := testHome(t) + defsDir := filepath.Join(home, "services") + if err := os.MkdirAll(defsDir, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(defsDir, "ajean-strata.json") + + // pas de définition : erreur lisible avec le chemin à créer + if _, err := loadExtServiceDef("ajean-strata"); err == nil { + t.Fatal("définition absente acceptée") + } + + // définition complète, chemins relatifs résolus depuis le dossier services/ + good := `{"command":"bin\\serve.exe","args":["--port","8080"],"cwd":"strata"}` + if err := os.WriteFile(p, []byte(good), 0o644); err != nil { + t.Fatal(err) + } + def, err := loadExtServiceDef("ajean-strata") + if err != nil { + t.Fatalf("parsing : %v", err) + } + if def.Command != filepath.Join(defsDir, "bin", "serve.exe") { + t.Fatalf("commande relative : %q", def.Command) + } + if def.Cwd != filepath.Join(defsDir, "strata") { + t.Fatalf("cwd relatif : %q", def.Cwd) + } + if len(def.Args) != 2 || def.Args[1] != "8080" { + t.Fatalf("args : %v", def.Args) + } + + // command manquant + bad := filepath.Join(defsDir, "ajean-bad.json") + if err := os.WriteFile(bad, []byte(`{"args":["x"]}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadExtServiceDef("ajean-bad"); err == nil { + t.Fatal("définition sans command acceptée") + } + + // JSON cassé + if err := os.WriteFile(bad, []byte(`{`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadExtServiceDef("ajean-bad"); err == nil { + t.Fatal("JSON cassé accepté") + } +} + +func TestExtUnitStateTracking(t *testing.T) { + testHome(t) + // jamais démarré → inactive, pas de PIDs + if s := unitActiveState("ajean-strata"); s != "inactive" { + t.Fatalf("état initial : %q", s) + } + if pids := unitPIDs("ajean-strata"); pids != nil { + t.Fatalf("pids initial : %v", pids) + } + if pid := extUnitPID("ajean-strata"); pid != 0 { + t.Fatalf("pid initial : %d", pid) + } + + // PID vivant (ce processus même) → active, pids connus + if s := unitActiveStateWithPID("ajean-strata", os.Getpid()); s != "active" { + t.Fatalf("état vivant : %q", s) + } + if pids := unitPIDs("ajean-strata"); len(pids) != 1 || pids[0] != os.Getpid() { + t.Fatalf("pids vivants : %v", pids) + } + + // PID mort → failed (le service s'est arrêté) + if s := unitActiveStateWithPID("ajean-strata", 99999999); s != "failed" { + t.Fatalf("état mort : %q", s) + } +} + +// unitActiveStateWithPID pose un fichier PID puis lit l'état : le vrai +// unitActiveState dépend du PID réel, qu'on ne peut pas contrôler dans un test. +func unitActiveStateWithPID(unit string, pid int) string { + if pid > 0 { + if err := os.WriteFile(extPidPath(unit), []byte(strconv.Itoa(pid)), 0o644); err != nil { + panic(err) + } + } else { + _ = os.Remove(extPidPath(unit)) + } + return unitActiveState(unit) +} + +// L'exclusivité GPU est la garantie de fond : le flux de bascule ( +// SwitchToPreset) arrête TOUJOURS un moteur avant d'en démarrer un autre. +// Sous Linux c'est en plus Contraint par Conflicts=ajean-engine ; ici c'est +// l'ordre des appels, testé à travers syncExternalService comme sous Linux. +func TestWindowsExternalServiceMutex(t *testing.T) { + testHome(t) + home := AjeanHome() + defsDir := filepath.Join(home, "services") + if err := os.MkdirAll(defsDir, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(defsDir, "ajean-strata.json") + if err := os.WriteFile(p, []byte(`{"command":"bin\\strata.exe","args":["--port","8080"]}`), 0o644); err != nil { + t.Fatal(err) + } + + var calls []string + fakeStart := func(unit string) error { + calls = append(calls, "start "+unit) + // le « process » est ce test même : vivant, stoppable sans douleur + return os.WriteFile(extPidPath(unit), []byte(strconv.Itoa(os.Getpid())), 0o644) + } + fakeStop := func(unit string) error { + calls = append(calls, "stop "+unit) + return os.Remove(extPidPath(unit)) + } + realAction := unitAction + extUnitAction = func(unit, action string) error { + if action == "start" { + return fakeStart(unit) + } + return fakeStop(unit) + } + defer func() { extUnitAction = realAction }() + + steps := []struct { + cfg map[string]string + want []string + }{ + // bascule vers le preset Strata : le moteur tiers démarre + {map[string]string{"EXTERNAL": "1", "EXTERNAL_URL": "http://127.0.0.1:8080/v1", extKeyService: "ajean-strata"}, + []string{"start ajean-strata"}}, + // re-bascule : idempotent + {map[string]string{"EXTERNAL": "1", "EXTERNAL_URL": "http://127.0.0.1:8080/v1", extKeyService: "ajean-strata"}, + []string{"start ajean-strata"}}, + // bascule vers un preset local : le moteur tiers s'arrête AVANT + // que llama.cpp reparte (cette partie est testée par + // TestSyncExternalService ; ici on vérifie juste l'arrêt) + {map[string]string{"MODEL": "x.gguf"}, []string{"stop ajean-strata"}}, + {map[string]string{"MODEL": "x.gguf"}, nil}, + } + for i, s := range steps { + calls = nil + if err := syncExternalService(s.cfg); err != nil { + t.Fatalf("étape %d: %v", i, err) + } + if len(calls) != len(s.want) { + t.Fatalf("étape %d: appels %v, attendu %v", i, calls, s.want) + } + for j := range calls { + if calls[j] != s.want[j] { + t.Fatalf("étape %d: appels %v, attendu %v", i, calls, s.want) + } + } + } + + // après arrêt : plus rien de suivi, état propre + if s := unitActiveState("ajean-strata"); s != "inactive" { + t.Fatalf("état après arrêt : %q", s) + } +}