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:
MichaelandClaude Opus 5.5 committed 2026-10-14 07:38:37 +02:00
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.
+6
View File
@@ -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()
+98
View File
@@ -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)
}
}
+247
View File
@@ -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"));
}
}
+214
View File
@@ -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
View File
@@ -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;
+18 -2
View File
@@ -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)
}
}
+112
View File
@@ -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());
}