diff --git a/docs/superpowers/specs/2026-10-14-rust-migration-lot3-design.md b/docs/superpowers/specs/2026-10-14-rust-migration-lot3-design.md new file mode 100644 index 0000000..52fd9da --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-rust-migration-lot3-design.md @@ -0,0 +1,49 @@ +# Migration Rust — lot 3 : supervision du moteur + +Date : 2026-10-14 · Statut : livré · Prérequis : lots 1-2. + +## Découpage + +Le « moteur » en conteneur, c'est deux couches de taille très différente : + +1. **La supervision du processus** (~350 lignes Go : `sys_service_container.go`, + `sys_proctree_linux.go`) — lancer `loki serve` détaché, fichier PID, + journal, arrêter tout l'arbre (Strata compris) et attendre que la VRAM + soit rendue. +2. **La ligne de commande** de llama-server / Strata (~9 000 lignes : + `backend_serve*.go`, presets, MoE, spéculatif, threads, cache…), là où + s'applique la règle « ne jamais dénaturer les modèles ». + +Le lot 3 porte la couche 1. La couche 2 reste au Go (`loki serve`) et fera +l'objet d'un lot dédié, parce que chaque option y a été réglée contre un +défaut mesuré. + +## Ce qui change + +- `loki-rs engine start|stop|restart|status` : port à l'identique de la + supervision Go — chef de session (`setsid`), fichier PID et journal aux + **mêmes chemins** (`$LOKI_HOME/.pid|.log`), contrôle à 2 s avec + les 20 dernières lignes du journal en cas d'échec, arrêt de l'arbre + (session, groupe, filiation ; SIGTERM, 4 s, SIGKILL, 20 s au plus), mêmes + messages. +- Le Go garde ses garde-fous (optimisation en cours via `serviceAction`, + preset externe) puis délègue au `loki-rs` voisin de son binaire. + `loki logs`, `enable`/`disable` restent au Go. +- Porte de sortie : `LOKI_ENGINE_SUPERVISOR=go`, ou absence de `loki-rs`. +- Différence assumée : le contrôle « démarré ? » regarde si l'enfant est + sorti (`try_wait`), pas le signal 0 — un enfant mort reste un zombie, qui + répond encore au signal 0. +- L'arrêt exclut de l'arbre `loki-rs` lui-même ET son parent (le Go qui l'a + appelé), comme le Go excluait son propre PID. + +## Tests + +- Rust (toutes plateformes) : `/proc//stat` avec parenthèses dans le + nom, arbre par session/groupe/filiation avec exclusion de l'appelant. +- Rust (Linux, CI) : faux `loki serve` qui lance un enfant dans sa session et + un « moteur » dans SA PROPRE session (comme Strata) — démarrage, « déjà + démarré », arrêt de tout l'arbre, redémarrage, échec avec journal, PID + nettoyé. +- Go (Linux, CI) : choix du `loki-rs` voisin (absent, non exécutable, + `LOKI_ENGINE_SUPERVISOR=go`), appel réel avec l'environnement attendu, + erreur rendue = dernière ligne d'erreur du superviseur. diff --git a/internal/loki/sys_service_container.go b/internal/loki/sys_service_container.go index 6f36d43..2ebd8e0 100644 --- a/internal/loki/sys_service_container.go +++ b/internal/loki/sys_service_container.go @@ -38,6 +38,12 @@ func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") } func userSvcAction(action string) error { + // Supervision confiée à loki-rs quand il est là (sys_service_rust_linux.go). + if rs := rustEngineSupervisor(); rs != "" { + if handled, err := rustUserSvcAction(rs, action); handled { + return err + } + } switch action { case "start": return userSvcStart() diff --git a/internal/loki/sys_service_rust_linux.go b/internal/loki/sys_service_rust_linux.go new file mode 100644 index 0000000..68a88cc --- /dev/null +++ b/internal/loki/sys_service_rust_linux.go @@ -0,0 +1,98 @@ +//go:build linux + +package loki + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Supervision du moteur déléguée à la façade Rust (lot 3 de la migration, +// rust/loki-rs/src/engine). En conteneur, start/stop/restart/status passent +// par `loki-rs engine ` : mêmes fichiers PID et journal, même arrêt de +// tout l'arbre, mêmes délais. Les garde-fous restent ici, en amont +// (serviceAction : optimisation en cours ; userSvcAction : preset externe) ; +// `loki serve` construit toujours la ligne de commande du moteur. +// +// Repli sur la supervision Go si loki-rs est absent à côté de ce binaire, ou +// si LOKI_ENGINE_SUPERVISOR=go (porte de sortie). + +// rustEngineSupervisorFor : le loki-rs voisin de exe, s'il doit superviser. +func rustEngineSupervisorFor(exe string) string { + if strings.EqualFold(strings.TrimSpace(os.Getenv("LOKI_ENGINE_SUPERVISOR")), "go") || exe == "" { + return "" + } + p := filepath.Join(filepath.Dir(exe), "loki-rs") + st, err := os.Stat(p) + if err != nil || st.IsDir() || st.Mode()&0o111 == 0 { + return "" + } + return p +} + +func rustEngineSupervisor() string { + exe, err := os.Executable() + if err != nil { + return "" + } + return rustEngineSupervisorFor(exe) +} + +// rustSvcAction lance `loki-rs engine `. Sa sortie s'affiche telle +// quelle ; en cas d'échec, sa dernière ligne d'erreur devient l'erreur rendue +// (c'est elle que l'UI montre), sans le préfixe du binaire. +func rustSvcAction(rs, action string) error { + self, err := os.Executable() + if err != nil { + return err + } + var stderr bytes.Buffer + cmd := exec.Command(rs, "engine", action) + cmd.Stdout = os.Stdout + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) + cmd.Env = append(os.Environ(), + "LOKI_GO_BIN="+self, + "LOKI_HOME="+LokiHome(), + "LOKI_SERVICE="+serviceName(), + ) + if err := cmd.Run(); err != nil { + if msg := lastErrorLine(stderr.String()); msg != "" { + return errors.New(msg) + } + return fmt.Errorf("loki-rs engine %s : %w", action, err) + } + return nil +} + +func lastErrorLine(s string) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + last := strings.TrimSpace(lines[len(lines)-1]) + return strings.TrimSpace(strings.TrimPrefix(last, "[loki-rs engine]")) +} + +// rustUserSvcAction : version déléguée de userSvcAction. handled=false laisse +// l'action au Go (logs, enable/disable). +func rustUserSvcAction(rs, action string) (handled bool, err error) { + switch action { + case "start", "restart": + // Preset externe : `loki serve` sortirait aussitôt (rien à lancer), + // et le superviseur le prendrait pour un plantage. + if isExternalConfig(ReadConfig()) { + if action == "restart" { + _ = rustSvcAction(rs, "stop") + } + fmt.Printf("%s preset externe actif : pas de moteur local à démarrer\n", dim("[info]")) + return true, nil + } + return true, rustSvcAction(rs, action) + case "stop", "status": + return true, rustSvcAction(rs, action) + } + return false, nil +} diff --git a/internal/loki/sys_service_rust_linux_test.go b/internal/loki/sys_service_rust_linux_test.go new file mode 100644 index 0000000..915e9d2 --- /dev/null +++ b/internal/loki/sys_service_rust_linux_test.go @@ -0,0 +1,62 @@ +//go:build linux + +package loki + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSuperviseurRustVoisin(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "loki") + t.Setenv("LOKI_ENGINE_SUPERVISOR", "") + if got := rustEngineSupervisorFor(exe); got != "" { + t.Fatalf("sans loki-rs voisin : %q", got) + } + rs := filepath.Join(dir, "loki-rs") + if err := os.WriteFile(rs, []byte("#!/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + if got := rustEngineSupervisorFor(exe); got != "" { + t.Fatalf("loki-rs non exécutable retenu : %q", got) + } + if err := os.Chmod(rs, 0o755); err != nil { + t.Fatal(err) + } + if got := rustEngineSupervisorFor(exe); got != rs { + t.Fatalf("loki-rs voisin : %q, attendu %q", got, rs) + } + t.Setenv("LOKI_ENGINE_SUPERVISOR", "go") + if got := rustEngineSupervisorFor(exe); got != "" { + t.Fatalf("LOKI_ENGINE_SUPERVISOR=go ignoré : %q", got) + } +} + +// Le superviseur Rust est réellement appelé, avec le bon environnement, et sa +// dernière ligne d'erreur devient l'erreur rendue à l'UI. +func TestActionDelegueeAuSuperviseurRust(t *testing.T) { + home := testHome(t) + dir := t.TempDir() + trace := filepath.Join(dir, "trace") + rs := filepath.Join(dir, "loki-rs") + script := "#!/bin/sh\n" + + "echo \"$1 $2 $LOKI_HOME $LOKI_SERVICE\" > " + trace + "\n" + + "[ \"$2\" = start ] && { echo '[loki-rs engine] service loki-engine non démarré' >&2; exit 1; }\n" + + "exit 0\n" + if err := os.WriteFile(rs, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + if err := rustSvcAction(rs, "stop"); err != nil { + t.Fatalf("stop : %v", err) + } + b, _ := os.ReadFile(trace) + if want := "engine stop " + home + " " + serviceName() + "\n"; string(b) != want { + t.Fatalf("appel : %q, attendu %q", b, want) + } + err := rustSvcAction(rs, "start") + if err == nil || err.Error() != "service loki-engine non démarré" { + t.Fatalf("erreur rendue : %v", err) + } +} diff --git a/rust/loki-rs/src/engine/mod.rs b/rust/loki-rs/src/engine/mod.rs new file mode 100644 index 0000000..098a7b3 --- /dev/null +++ b/rust/loki-rs/src/engine/mod.rs @@ -0,0 +1,247 @@ +//! Supervision du moteur en conteneur — port de `sys_service_container.go` +//! (lot 3 de la migration). +//! +//! `loki-rs engine start|stop|restart|status` lance `loki serve` (Go : il +//! construit toujours la ligne de commande de llama-server ou de Strata) en +//! chef de session, détaché, et l'arrête avec tout son arbre. Fichier PID et +//! journal aux MÊMES chemins que le Go (`$LOKI_HOME/.pid|.log`) : +//! `serviceIsActive`, l'UI et `loki logs` lisent les mêmes fichiers. +//! +//! Le Go garde ses garde-fous (optimisation en cours, preset externe) et +//! délègue ici ensuite (`sys_service_rust_linux.go`). + +pub mod proctree; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct Paths { + pub home: PathBuf, + pub service: String, +} + +impl Paths { + pub fn from_env() -> Self { + let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty()); + Paths { + home: PathBuf::from(env("LOKI_HOME").unwrap_or_else(|| "/data".into())), + service: env("LOKI_SERVICE").unwrap_or_else(|| "loki-engine".into()), + } + } + pub fn pid(&self) -> PathBuf { + self.home.join(format!("{}.pid", self.service)) + } + pub fn log(&self) -> PathBuf { + self.home.join(format!("{}.log", self.service)) + } +} + +#[derive(Debug, Clone)] +pub struct Opts { + /// Binaire Go à lancer en `serve`. + pub go_bin: PathBuf, + /// Délai avant de juger que le démarrage a tenu (2 s comme le Go). + pub check_delay: Duration, + /// SIGTERM → SIGKILL (4 s comme le Go). + pub grace: Duration, + /// Attente maximale après SIGKILL (20 s comme le Go). + pub settle: Duration, +} + +impl Opts { + pub fn new(go_bin: PathBuf) -> Self { + Opts { + go_bin, + check_delay: Duration::from_secs(2), + grace: Duration::from_secs(4), + settle: Duration::from_secs(20), + } + } +} + +pub fn read_pid(path: &Path) -> i32 { + std::fs::read_to_string(path) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0) +} + +/// Les `n` dernières lignes de `text`, comme `tailFile` du Go. +pub fn tail_lines(text: &str, n: usize) -> String { + let lines: Vec<&str> = text.trim_end_matches('\n').split('\n').collect(); + let from = lines.len().saturating_sub(n); + lines[from..].join("\n") + "\n" +} + +/// Exécute une action ; les messages vont sur la sortie standard, l'erreur +/// éventuelle est rendue à l'appelant (le Go l'affiche telle quelle). +pub fn run(action: &str, paths: &Paths, opts: &Opts) -> Result<(), String> { + imp::run(action, paths, opts) +} + +#[cfg(target_os = "linux")] +mod imp { + use super::proctree::linux::{pid_alive, stop_proc_tree}; + use super::*; + use std::fs::OpenOptions; + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + + pub fn run(action: &str, paths: &Paths, opts: &Opts) -> Result<(), String> { + match action { + "start" => start(paths, opts), + "stop" => stop(paths, opts, true), + "restart" => { + stop(paths, opts, false)?; + std::thread::sleep(Duration::from_millis(500)); + start(paths, opts) + } + "status" => { + let pid = read_pid(&paths.pid()); + if pid_alive(pid) { + println!("[ok] {}: actif (PID {pid})", paths.service); + } else { + println!("[info] {}: arrêté", paths.service); + } + println!(" logs : {}", paths.log().display()); + Ok(()) + } + other => Err(format!("action inconnue: {other}")), + } + } + + fn start(paths: &Paths, opts: &Opts) -> Result<(), String> { + let pid = read_pid(&paths.pid()); + if pid_alive(pid) { + println!("[info] déjà démarré (PID {pid})"); + return Ok(()); + } + std::fs::create_dir_all(&paths.home).map_err(|e| e.to_string())?; + let log = OpenOptions::new() + .create(true) + .append(true) + .open(paths.log()) + .map_err(|e| format!("ouverture du log {}: {e}", paths.log().display()))?; + let log2 = log.try_clone().map_err(|e| e.to_string())?; + let mut cmd = Command::new(&opts.go_bin); + cmd.arg("serve") + .stdin(Stdio::null()) + .stdout(log) + .stderr(log2) + // Même répertoire de travail que sous systemd : les chemins + // relatifs de config.env se résolvent depuis LOKI_HOME. + .current_dir(&paths.home); + // SAFETY: setsid(2) est async-signal-safe, seul appel entre fork et exec. + // Chef de session : l'enfant survit à ce processus, et un signal au + // groupe (-pid) arrête aussi llama-server. + unsafe { + cmd.pre_exec(|| { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = cmd + .spawn() + .map_err(|e| format!("démarrage de 'loki serve': {e}"))?; + let pid = child.id() as i32; + std::fs::write(paths.pid(), pid.to_string()) + .map_err(|e| format!("écriture du PID: {e}"))?; + + std::thread::sleep(opts.check_delay); + // try_wait et non kill(pid, 0) : l'enfant mort reste un zombie tant + // que ce processus vit, et un zombie répond encore au signal 0. + if matches!(child.try_wait(), Ok(None)) { + println!("[ok] {}: démarré (PID {pid})", paths.service); + println!( + " logs: {} (loki logs pour suivre)", + paths.log().display() + ); + // Pas de wait : l'enfant est rattaché à PID 1 (tini) à notre sortie. + return Ok(()); + } + let tail = std::fs::read_to_string(paths.log()) + .map(|t| tail_lines(&t, 20)) + .unwrap_or_default(); + println!( + "[ERREUR] {}: le processus s'est arrêté — derniers logs :", + paths.service + ); + println!("------------------------------------------------"); + print!("{tail}"); + println!("------------------------------------------------"); + println!("→ loki logs pour plus de détails\n→ loki edit pour corriger config.env"); + let _ = std::fs::remove_file(paths.pid()); + Err(format!("service {} non démarré", paths.service)) + } + + fn stop(paths: &Paths, opts: &Opts, verbose: bool) -> Result<(), String> { + let pid = read_pid(&paths.pid()); + // Ni ce processus, ni le Go qui l'a lancé ne font partie de l'arbre. + let me = std::process::id() as i32; + // SAFETY: getppid(2) ne peut pas échouer. + let parent = unsafe { libc::getppid() }; + let exclude = [me, parent]; + let tree = if pid > 0 { + super::proctree::proc_tree_of(&super::proctree::linux::proc_table(), pid, &exclude) + } else { + Vec::new() + }; + // Le chef parti, ses enfants peuvent tourner encore (moteur de + // Strata) : « rien à arrêter » seulement si l'arbre entier est vide. + if tree.is_empty() { + let _ = std::fs::remove_file(paths.pid()); + if verbose { + println!("[info] aucun service en cours d'exécution"); + } + return Ok(()); + } + let left = stop_proc_tree(pid, &exclude, opts.grace, opts.settle); + let _ = std::fs::remove_file(paths.pid()); + if let Some(first) = left.first() { + eprintln!( + "[attention] {} processus du moteur toujours là après SIGKILL (PID {}…) : \ + la mémoire du GPU peut ne pas être encore rendue", + left.len(), + first.pid + ); + } + if verbose { + println!("[ok] arrêté"); + } + Ok(()) + } +} + +#[cfg(not(target_os = "linux"))] +mod imp { + use super::*; + + pub fn run(_: &str, _: &Paths, _: &Opts) -> Result<(), String> { + Err("supervision du moteur par loki-rs : Linux (conteneur) seulement".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fin_de_journal() { + assert_eq!(tail_lines("a\nb\nc\n", 2), "b\nc\n"); + assert_eq!(tail_lines("a\nb", 5), "a\nb\n"); + assert_eq!(tail_lines("", 3), "\n"); + } + + #[test] + fn chemins() { + let p = Paths { + home: PathBuf::from("/data"), + service: "loki-engine".into(), + }; + assert_eq!(p.pid(), Path::new("/data/loki-engine.pid")); + assert_eq!(p.log(), Path::new("/data/loki-engine.log")); + } +} diff --git a/rust/loki-rs/src/engine/proctree.rs b/rust/loki-rs/src/engine/proctree.rs new file mode 100644 index 0000000..556a0c1 --- /dev/null +++ b/rust/loki-rs/src/engine/proctree.rs @@ -0,0 +1,214 @@ +//! Arbre de processus du moteur — port de `sys_proctree_linux.go`. +//! +//! Un processus est 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. + +use std::collections::{HashMap, HashSet}; + +/// Les champs de `/proc//stat` utiles ici. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcStat { + pub state: String, + pub ppid: i32, + pub pgrp: i32, + pub sid: i32, + pub start: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ProcRef { + pub pid: i32, + pub start: String, +} + +/// Lit une ligne de `/proc//stat`. Le nom (champ 2, entre parenthèses) +/// peut contenir des blancs et des parenthèses : on repart de la DERNIÈRE +/// fermante. +pub fn parse_proc_stat(s: &str) -> Option { + let i = s.rfind(')')?; + // f[0] = champ 3 (état), f[1] ppid, f[2] groupe, f[3] session, + // f[19] instant de démarrage (champ 22). + let f: Vec<&str> = s[i + 1..].split_whitespace().collect(); + if f.len() < 20 { + return None; + } + Some(ProcStat { + state: f[0].to_string(), + ppid: f[1].parse().unwrap_or(0), + pgrp: f[2].parse().unwrap_or(0), + sid: f[3].parse().unwrap_or(0), + start: f[19].to_string(), + }) +} + +/// Le chef `root` et tout ce qui en descend : sa session et son groupe (il est +/// lancé chef de session), 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 tout signal : une +/// fois le chef mort, ses orphelins sont rattachés à PID 1 et la filiation est +/// perdue. Les PID de `exclude` (l'appelant) n'en font jamais partie. +pub fn proc_tree_of(table: &HashMap, root: i32, exclude: &[i32]) -> Vec { + let mut inside: HashSet = table + .iter() + .filter(|(pid, st)| { + !exclude.contains(pid) && (**pid == root || st.sid == root || st.pgrp == root) + }) + .map(|(pid, _)| *pid) + .collect(); + // Fermeture par la filiation, jusqu'à ce que plus rien ne s'ajoute. + loop { + let more: Vec = table + .iter() + .filter(|(pid, st)| { + !inside.contains(pid) && !exclude.contains(pid) && inside.contains(&st.ppid) + }) + .map(|(pid, _)| *pid) + .collect(); + if more.is_empty() { + break; + } + inside.extend(more); + } + let mut refs: Vec = inside + .into_iter() + .map(|pid| ProcRef { + pid, + start: table[&pid].start.clone(), + }) + .collect(); + refs.sort_by_key(|r| r.pid); + refs +} + +#[cfg(target_os = "linux")] +pub mod linux { + use super::*; + use std::time::{Duration, Instant}; + + pub fn read_proc_stat(pid: i32) -> Option { + let s = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + parse_proc_stat(&s) + } + + /// Tous les processus vivants (un zombie a fini de tourner : sa mémoire, + /// VRAM comprise, est déjà rendue). + pub fn proc_table() -> HashMap { + let Ok(rd) = std::fs::read_dir("/proc") else { + return HashMap::new(); + }; + rd.flatten() + .filter_map(|e| e.file_name().to_str()?.parse::().ok()) + .filter_map(|pid| read_proc_stat(pid).map(|st| (pid, st))) + .filter(|(_, st)| st.state != "Z") + .collect() + } + + /// Ni disparu, ni zombie, ni remplacé par un autre sous le même PID. + pub fn ref_alive(r: &ProcRef) -> bool { + read_proc_stat(r.pid).is_some_and(|st| st.state != "Z" && st.start == r.start) + } + + pub fn refs_alive(refs: &[ProcRef]) -> Vec { + refs.iter().filter(|r| ref_alive(r)).cloned().collect() + } + + fn kill(pid: i32, sig: i32) -> bool { + // SAFETY: kill(2) n'a pas d'effet mémoire ; un PID absent rend -1. + unsafe { libc::kill(pid, sig) == 0 } + } + + /// Le processus existe et n'est pas un zombie. + pub fn pid_alive(pid: i32) -> bool { + pid > 0 && kill(pid, 0) && read_proc_stat(pid).is_some_and(|st| st.state != "Z") + } + + /// Arrête le service lancé chef de session (`pid`) et N'EN REVIENT QU'UNE + /// FOIS TOUT L'ARBRE PARTI. Attendre le seul chef ne suffit pas : avec + /// Strata, le moteur qui tient la VRAM finit de s'arrêter après lui, et la + /// relance qui suivait démarrait sur une carte encore pleine. + /// + /// SIGTERM à tous, `grace` pour finir proprement, SIGKILL aux survivants, + /// puis au plus `settle` pour que le noyau les ait retirés. Renvoie ceux + /// qui tournent encore au-delà. + pub fn stop_proc_tree( + pid: i32, + exclude: &[i32], + grace: Duration, + settle: Duration, + ) -> Vec { + let refs = proc_tree_of(&proc_table(), pid, exclude); + if refs.is_empty() { + return Vec::new(); + } + if !kill(-pid, libc::SIGTERM) { + kill(pid, libc::SIGTERM); + } + for r in &refs { + kill(r.pid, libc::SIGTERM); + } + if wait_refs(&refs, grace).is_empty() { + return Vec::new(); + } + kill(-pid, libc::SIGKILL); + for r in refs_alive(&refs) { + kill(r.pid, libc::SIGKILL); + } + wait_refs(&refs, settle) + } + + /// Attend que tous aient disparu, au plus `wait`. Renvoie les survivants. + pub fn wait_refs(refs: &[ProcRef], wait: Duration) -> Vec { + let deadline = Instant::now() + wait; + loop { + let left = refs_alive(refs); + if left.is_empty() || Instant::now() >= deadline { + return left; + } + std::thread::sleep(Duration::from_millis(100)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn st(ppid: i32, pgrp: i32, sid: i32) -> ProcStat { + ProcStat { + state: "S".into(), + ppid, + pgrp, + sid, + start: "42".into(), + } + } + + #[test] + fn stat_avec_parentheses_dans_le_nom() { + let line = "1234 (python3 (serve) x) S 1 1234 1234 0 -1 4194560 0 0 0 0 \ + 0 0 0 0 20 0 3 0 987654 0 0"; + let s = parse_proc_stat(line).unwrap(); + assert_eq!(s.state, "S"); + assert_eq!((s.ppid, s.pgrp, s.sid), (1, 1234, 1234)); + assert_eq!(s.start, "987654"); + assert!(parse_proc_stat("12 (court) S 1 2").is_none()); + assert!(parse_proc_stat("sans parenthèse").is_none()); + } + + #[test] + fn arbre_session_groupe_et_filiation() { + let mut t = HashMap::new(); + t.insert(100, st(1, 100, 100)); // chef (loki serve, chef de session) + t.insert(101, st(100, 100, 100)); // llama-server / serveur Strata + t.insert(200, st(101, 200, 200)); // moteur Strata : sa propre session + t.insert(201, st(200, 200, 200)); // et son enfant + t.insert(300, st(1, 300, 300)); // étranger + t.insert(102, st(100, 100, 100)); // l'appelant, dans l'arbre : exclu + let pids: Vec = proc_tree_of(&t, 100, &[102]) + .iter() + .map(|r| r.pid) + .collect(); + assert_eq!(pids, vec![100, 101, 200, 201]); + assert!(proc_tree_of(&t, 999, &[]).is_empty()); + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index e978ff4..b34f1c6 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -2,12 +2,14 @@ //! //! - `proxy` : façade HTTP/SSE/WebSocket devant le serveur Go ; //! - `child` : supervision du processus `loki web` (Go) ; +//! - `engine` : supervision du moteur (`loki serve` et son arbre, lot 3) ; //! - `ui` : UI web statique embarquée (lot 2) ; //! - `auth` : authentification de l'API de pilotage (lot 2) ; //! - `update` : mise à jour des binaires au démarrage du conteneur. pub mod auth; pub mod child; +pub mod engine; pub mod proxy; pub mod ui; pub mod update; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index ba58908..979c44e 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -4,10 +4,11 @@ //! loki-rs serve façade sur LOKI_WEB_PORT (8090), Go sur 127.0.0.1:LOKI_GO_PORT (8091) //! loki-rs self-update met à jour les binaires dans $LOKI_HOME/bin (jamais bloquant) //! loki-rs which-bin dossier d'où prendre loki et loki-rs +//! loki-rs engine supervision du moteur (conteneur) //! loki-rs version //! ``` -use loki_rs::{auth, child, proxy, update}; +use loki_rs::{auth, child, engine, proxy, update}; use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; @@ -17,6 +18,19 @@ fn main() -> ExitCode { let cmd = std::env::args().nth(1).unwrap_or_default(); match cmd.as_str() { "serve" => serve(), + "engine" => { + let action = std::env::args().nth(2).unwrap_or_default(); + let go_bin = child::go_bin(std::env::var("LOKI_GO_BIN").ok(), &exe()); + let opts = engine::Opts::new(go_bin); + match engine::run(&action, &engine::Paths::from_env(), &opts) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + // Dernière ligne de stderr : le Go en fait son message d'erreur. + eprintln!("[loki-rs engine] {e}"); + ExitCode::FAILURE + } + } + } "self-update" => { let outcome = update::run(&update::UpdateConfig::from_env()); eprintln!("[loki-rs update] {}", outcome.message()); @@ -44,7 +58,9 @@ fn main() -> ExitCode { ExitCode::SUCCESS } _ => { - eprintln!("usage : loki-rs serve | self-update | which-bin | version"); + eprintln!( + "usage : loki-rs serve | engine | self-update | which-bin | version" + ); ExitCode::from(2) } } diff --git a/rust/loki-rs/tests/engine.rs b/rust/loki-rs/tests/engine.rs new file mode 100644 index 0000000..c25c11e --- /dev/null +++ b/rust/loki-rs/tests/engine.rs @@ -0,0 +1,112 @@ +//! Supervision du moteur (lot 3) — Linux seulement : /proc, setsid, signaux. +#![cfg(target_os = "linux")] + +use loki_rs::engine::proctree::linux::pid_alive; +use loki_rs::engine::{self, Opts, Paths}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// Faux binaire Go : `serve` lance un « llama-server » dans sa session et un +/// « moteur Strata » qui prend SA PROPRE session (comme le vrai), puis attend. +fn faux_go(dir: &Path, corps: &str) -> PathBuf { + let p = dir.join("faux-loki"); + std::fs::write(&p, format!("#!/bin/sh\n{corps}\n")).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p +} + +fn opts(go: PathBuf) -> Opts { + Opts { + go_bin: go, + check_delay: Duration::from_millis(500), + grace: Duration::from_secs(2), + settle: Duration::from_secs(5), + } +} + +fn paths(home: &Path) -> Paths { + Paths { + home: home.to_path_buf(), + service: "loki-engine".into(), + } +} + +fn lire_pid(p: &Path) -> i32 { + std::fs::read_to_string(p).unwrap().trim().parse().unwrap() +} + +#[test] +fn demarre_puis_arrete_tout_l_arbre() { + let t = tempfile::tempdir().unwrap(); + let home = t.path(); + let go = faux_go( + home, + r#"[ "$1" = serve ] || exit 9 +echo "serve lancé dans $(pwd)" +sleep 300 & +echo $! > llama.pid +setsid sleep 300 & +echo $! > strata.pid +wait"#, + ); + let (p, o) = (paths(home), opts(go)); + + engine::run("start", &p, &o).unwrap(); + let chef = lire_pid(&p.pid()); + assert!(pid_alive(chef)); + std::thread::sleep(Duration::from_millis(300)); + let llama = lire_pid(&home.join("llama.pid")); + let strata = lire_pid(&home.join("strata.pid")); + assert!(pid_alive(llama) && pid_alive(strata)); + let log = std::fs::read_to_string(p.log()).unwrap(); + assert!(log.contains("serve lancé"), "journal : {log}"); + + // Déjà démarré : rien de relancé. + engine::run("start", &p, &o).unwrap(); + assert_eq!(lire_pid(&p.pid()), chef); + + engine::run("stop", &p, &o).unwrap(); + assert!(!p.pid().exists()); + for (nom, pid) in [("chef", chef), ("llama", llama), ("strata", strata)] { + assert!(!pid_alive(pid), "{nom} (PID {pid}) survit à l'arrêt"); + } + // Rien à arrêter : pas d'erreur. + engine::run("stop", &p, &o).unwrap(); +} + +#[test] +fn redemarre() { + let t = tempfile::tempdir().unwrap(); + let go = faux_go(t.path(), "exec sleep 300"); + let (p, o) = (paths(t.path()), opts(go)); + engine::run("start", &p, &o).unwrap(); + let avant = lire_pid(&p.pid()); + engine::run("restart", &p, &o).unwrap(); + let apres = lire_pid(&p.pid()); + assert_ne!(avant, apres); + assert!(!pid_alive(avant) && pid_alive(apres)); + engine::run("stop", &p, &o).unwrap(); +} + +#[test] +fn echec_au_demarrage() { + let t = tempfile::tempdir().unwrap(); + let go = faux_go(t.path(), "echo 'modèle introuvable'; exit 1"); + let (p, o) = (paths(t.path()), opts(go)); + let err = engine::run("start", &p, &o).unwrap_err(); + assert_eq!(err, "service loki-engine non démarré"); + assert!(!p.pid().exists(), "PID laissé après un échec"); + assert!( + std::fs::read_to_string(p.log()) + .unwrap() + .contains("modèle introuvable") + ); +} + +#[test] +fn action_inconnue() { + let t = tempfile::tempdir().unwrap(); + let (p, o) = (paths(t.path()), opts(PathBuf::from("/bin/true"))); + assert!(engine::run("enable", &p, &o).is_err()); +}