mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-14 10:48:35 +02:00
Façade Rust, lot 3 : la supervision du moteur passe à loki-rs
En conteneur, start/stop/restart/status du moteur passent par `loki-rs engine <action>`, port à l'identique de la supervision Go : `loki serve` lancé chef de session, fichier PID et journal aux mêmes chemins (serviceIsActive, l'UI et `loki logs` lisent les mêmes fichiers), contrôle à 2 s avec la fin du journal en cas d'échec, arrêt de tout l'arbre — session, groupe et filiation, pour le moteur de Strata qui prend sa propre session — avec les mêmes délais (SIGTERM, 4 s, SIGKILL, 20 s). Le Go garde ses garde-fous (optimisation en cours, preset externe) puis délègue au loki-rs voisin de son binaire ; la dernière ligne d'erreur du superviseur devient l'erreur montrée par l'UI. LOKI_ENGINE_SUPERVISOR=go revient à l'ancienne supervision. `loki serve`, qui construit la ligne de commande du moteur, ne change pas : il aura son propre lot. Le contrôle « démarré ? » regarde si l'enfant est sorti plutôt que le signal 0 : un enfant mort reste un zombie tant que son parent vit, et un zombie répond encore au signal 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4fa4488cc
commit
a672760f3c
9 files changed
+808
-2
No files matched your search
@@ -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/<service>.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/<pid>/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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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 <action>` : 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 <action>`. 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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/<service>.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"));
|
||||
}
|
||||
}
|
||||
@@ -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/<pid>/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/<pid>/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<ProcStat> {
|
||||
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<i32, ProcStat>, root: i32, exclude: &[i32]) -> Vec<ProcRef> {
|
||||
let mut inside: HashSet<i32> = 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<i32> = 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<ProcRef> = 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<ProcStat> {
|
||||
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<i32, ProcStat> {
|
||||
let Ok(rd) = std::fs::read_dir("/proc") else {
|
||||
return HashMap::new();
|
||||
};
|
||||
rd.flatten()
|
||||
.filter_map(|e| e.file_name().to_str()?.parse::<i32>().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<ProcRef> {
|
||||
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<ProcRef> {
|
||||
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<ProcRef> {
|
||||
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<i32> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 <start|stop|restart|status> 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 <action> | self-update | which-bin | version"
|
||||
);
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user