Files
Loki/rust/loki-rs/src/engine/proctree.rs
T
MichaelandClaude Opus 5.5 a672760f3c 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>
2026-10-14 07:38:37 +02:00

215 lines
7.2 KiB
Rust

//! 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());
}
}