From 0d75e90386fd1345e84d92999619585acd1fd444 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 14 Oct 2026 17:49:24 +0200 Subject: [PATCH] =?UTF-8?q?Lot=204=20:=20=C3=A9tat=20du=20moteur=20port?= =?UTF-8?q?=C3=A9=20en=20Rust=20(engstatus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les aides qui nourrissent /api/status et /api/service/log passent dans loki-rs/src/engstatus.rs, à l'identique du Go : - model_load_error_from / is_backtrace_frame (modelLoadErrorFrom) : même ordre des cas, même sensibilité à la casse cas par cas, libellés identiques à l'octet près ; - service_log_tail : la fin du journal du moteur en conteneur ; - engine_load_pct (Linux, -1 ailleurs) : /proc//io et VmRSS rapportés à la taille du modèle, tranches comprises ; la résolution de MODEL= est portée localement (models/, $LOKI_MODEL_DIRS, et les dossiers de la base via engine_load_pct_dirs) en attendant crate::paths ; - llm_port, is_external, health_check (sonde /health sur localhost, 3 s, prêt seulement sur un 200, preset externe toujours prêt). Tests Go repris un pour un (LOAD_GUARD, Strata, OOM avant la pile d'appels, preset externe, tranches), plus une sonde contre un serveur hyper local : 200, 503, port fermé, connexion muette. Co-Authored-By: Claude Opus 5.5 --- rust/loki-rs/src/engstatus.rs | 961 +++++++++++++++++++++++++++++++++- 1 file changed, 960 insertions(+), 1 deletion(-) diff --git a/rust/loki-rs/src/engstatus.rs b/rust/loki-rs/src/engstatus.rs index 134237e..5ffcfc6 100644 --- a/rust/loki-rs/src/engstatus.rs +++ b/rust/loki-rs/src/engstatus.rs @@ -1 +1,960 @@ -//! Lot 4 : module en cours de portage. +//! État du moteur pour `/api/status` et `/api/service/log` (lot 4) — port des +//! aides Go : +//! +//! - `modelLoadErrorFrom` / `isBacktraceFrame` (`web_api.go`) : la cause d'un +//! chargement raté, lue dans le journal du moteur ; +//! - `serviceLogTail` en conteneur (`tailFile(logFilePath(), n)`) ; +//! - `engineLoadPct` (`sys_loadpct_linux.go`, -1 ailleurs) : le pourcentage +//! de chargement estimé d'après `/proc//io` et `VmRSS` ; +//! - `LLMPort` (`backend_config.go`) et `isExternalConfig` +//! (`backend_external.go`) ; +//! - `healthCheck` (`llm_client.go`) : sonde `/health` du llama-server local. +//! +//! La configuration (`ReadConfig()` du Go) est passée par l'appelant +//! (`Store::config`) : ce module ne parle pas à la base. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::engine::Paths; + +/// Fin du message de refus de LOAD_GUARD (`loadGuardMarker`, +/// `backend_serve_moe.go`), reconnue dans le journal du service. +pub const LOAD_GUARD_MARKER: &str = "LOAD_GUARD=off pour lancer quand même"; + +/// Première ligne d'un lancement de Strata (`strataServeMarker`, +/// `backend_strata.go`). +pub const STRATA_SERVE_MARKER: &str = "[loki serve] Strata"; + +/// Clé de configuration d'un preset externe (`extKeyFlag`) : "1" = externe. +pub const EXT_KEY_FLAG: &str = "EXTERNAL"; + +/// Port du llama-server quand la configuration n'en donne pas (ou un invalide). +pub const DEFAULT_PORT: u16 = 8080; + +// --------------------------------------------------------------------------- +// Cause d'un chargement raté +// --------------------------------------------------------------------------- + +const REASON_QUANT: &str = "format de quantification non reconnu par ce moteur"; +const REASON_ARCH: &str = "type/architecture de modèle inconnu de ce moteur"; + +/// `modelLoadErrorFrom` : la cause du DERNIER chargement raté visible dans le +/// journal, en clair pour l'UI ; "" quand ce chargement a réussi ou qu'aucune +/// tentative n'est visible. +pub fn model_load_error_from(log: &str) -> String { + if log.is_empty() { + return String::new(); + } + let lines: Vec<&str> = log.split('\n').collect(); + // On ne considère que ce qui suit la DERNIÈRE tentative de chargement. + let mut start = 0; + for (i, l) in lines.iter().enumerate() { + // Une pile d'appels cite les fonctions traversées + // (…server_context_impl10load_model…) : ce n'est pas une nouvelle + // tentative, et repartir de là perdait la cause écrite juste avant. + if is_backtrace_frame(l) { + continue; + } + // Un lancement refusé par LOAD_GUARD s'arrête avant tout chargement : + // c'est lui, la dernière tentative. + if l.contains("loading model") + || l.contains("load_model") + || l.contains(LOAD_GUARD_MARKER) + || l.contains(STRATA_SERVE_MARKER) + { + start = i; + } + } + let mut loaded = false; + let mut reason: &str = ""; + for l in &lines[start..] { + let l: &str = l; + let low = l.to_lowercase(); + let low = low.as_str(); + // Même ordre que le `switch` du Go : le premier cas vrai l'emporte. + if low.contains("model loaded") + || low.contains("server is listening") + || l.starts_with("ready: http://") + { + // serveur de Strata prêt + loaded = true; + } else if l.contains("Traceback (most recent call last)") { + // Serveur de Strata (Python) : une trace = il s'est arrêté. + if reason.is_empty() { + reason = "le serveur de Strata s'est arrêté (trace Python dans le journal)"; + } + } else if l.contains("[strata] config") { + reason = + "configuration de Strata refusée (détail dans le journal) — réinstalle le modèle"; + } else if low.contains("failed to create mtp context") + || low.contains("failed to load draft model") + || low.contains("failed to initialize speculative decoding context") + { + // Avant l'échec générique : c'est le brouillon qui manque, pas le modèle. + reason = "le décodage spéculatif n'a pas pu démarrer (tête MTP ou modèle brouillon) — SPEC=off dans le preset"; + } else if low.contains("split_mode_tensor requires flash_attn") + || low.contains("split_mode_tensor not implemented") + { + reason = + "le mode tensor (-sm tensor) n'a pas pu démarrer — SPLIT_MODE=off dans le preset"; + } else if l.contains(LOAD_GUARD_MARKER) { + reason = "lancement refusé : trop de poids resteraient en RAM avec ce mode de chargement — --load-mode mmap dans le preset, ou LOAD_GUARD=off pour passer outre"; + } else if l.contains("has offset") && l.contains("expected") { + reason = REASON_QUANT; + } else if low.contains("unknown model architecture") + || low.contains("unknown ftype") + || low.contains("unknown type") + || low.contains("unsupported") + { + if reason.is_empty() { + reason = REASON_ARCH; + } + } else if low.contains("error loading model") + || low.contains("failed to load model") + || low.contains("failed to read tensor data") + { + if reason.is_empty() { + reason = "échec du chargement du modèle"; + } + } else if low.contains("out of memory") + || low.contains("cudamalloc failed") + || low.contains("failed to allocate") + { + // Prioritaire sur l'échec générique : la cause mémoire doit gagner. + reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite, et vérifie qu'aucun autre programme n'occupe la carte (nvidia-smi)"; + } else if low.contains("ggml_cuda error") || low.contains("cuda error") { + if reason.is_empty() { + reason = "erreur CUDA — GPU indisponible ? (après un redémarrage du serveur : docker restart loki)"; + } + } else if low.contains("__libc_start_main") + || l.contains("SIGSEGV") + || l.contains("SIGABRT") + || low.contains("segmentation fault") + || low.contains("aborted (core dumped)") + { + // Backtrace = le moteur a PLANTÉ, pas « il charge encore ». PAS de + // motif « libggml » : les logs normaux citent les .so. + if reason.is_empty() { + reason = "le moteur a planté au démarrage (backtrace dans le journal)"; + } + } + } + if loaded || reason.is_empty() { + return String::new(); + } + // N'affirmer l'incompatibilité que quand le journal la montre vraiment + // (quant/architecture) ; sinon renvoyer vers le journal. + if reason == REASON_QUANT || reason == REASON_ARCH { + format!( + "Modèle incompatible avec le moteur : {reason}. Choisis un autre backend (édite le modèle → Moteur)." + ) + } else { + format!( + "Le modèle n'a pas pu être chargé ({reason}). Ouvre le journal du moteur pour le détail." + ) + } +} + +/// `isBacktraceFrame` : une ligne de pile d'appels de glibc +/// (« /chemin/lib.so(symbole+0x1c)[0x14bd…] »). +pub fn is_backtrace_frame(l: &str) -> bool { + l.contains(")[0x") +} + +// --------------------------------------------------------------------------- +// Journal du service +// --------------------------------------------------------------------------- + +/// `serviceLogTail(n)` en conteneur : les `n` dernières lignes du journal du +/// moteur (`$LOKI_HOME/.log`), "" si illisible. Octets invalides en +/// UTF-8 remplacés par U+FFFD (le Go les gardait bruts ; son encodage JSON +/// fait le même remplacement à la sortie). +pub fn service_log_tail(paths: &Paths, n: usize) -> String { + match std::fs::read(paths.log()) { + Ok(b) => crate::engine::tail_lines(&String::from_utf8_lossy(&b), n), + Err(_) => String::new(), + } +} + +// --------------------------------------------------------------------------- +// Configuration : port et preset externe +// --------------------------------------------------------------------------- + +/// Valeur brute de PORT comme `LLMPort` du Go (`strconv.Atoi`, > 0), sans +/// borne haute : un port > 65535 doit faire échouer la sonde, pas viser 8080. +fn llm_port_raw(cfg: &HashMap) -> i64 { + if let Some(p) = cfg.get("PORT") + && let Ok(n) = p.parse::() + && n > 0 + { + return n; + } + i64::from(DEFAULT_PORT) +} + +/// `LLMPort` : le port du serveur (clé PORT), 8080 par défaut. Un PORT hors de +/// 1..=65535 (que le Go aurait rendu tel quel) donne aussi 8080. +pub fn llm_port(cfg: &HashMap) -> u16 { + u16::try_from(llm_port_raw(cfg)).unwrap_or(DEFAULT_PORT) +} + +/// `isExternalConfig` : la configuration décrit-elle un endpoint externe ? +pub fn is_external(cfg: &HashMap) -> bool { + cfg.get(EXT_KEY_FLAG).is_some_and(|v| v.trim() == "1") +} + +// --------------------------------------------------------------------------- +// Sonde /health +// --------------------------------------------------------------------------- + +/// Délai de la sonde (`healthClient`, 3 s) : un moteur qui accepte la +/// connexion sans jamais répondre ne doit pas bloquer l'appelant. +const HEALTH_TIMEOUT: Duration = Duration::from_secs(3); + +/// `healthCheck` : preset externe → prêt sans sonder ; sinon GET +/// `http://localhost:/health`, prêt seulement sur un 200. +/// +/// « localhost » comme le Go : le connecteur de hyper résout par le système +/// et essaie chaque adresse (::1 puis 127.0.0.1, « happy eyeballs »), comme +/// le dialer de Go. +pub async fn health_check(cfg: &HashMap) -> bool { + if is_external(cfg) { + return true; + } + let port = llm_port_raw(cfg); + if port > i64::from(u16::MAX) { + // Go : « invalid port » au dial → false. hyper, lui, prendrait le port 80. + return false; + } + health_probe(&format!("http://localhost:{port}/health")).await +} + +async fn health_probe(url: &str) -> bool { + use bytes::Bytes; + use http_body_util::Empty; + use hyper_util::client::legacy::Client; + use hyper_util::rt::TokioExecutor; + + let Ok(uri) = url.parse::() else { + return false; + }; + let client = Client::builder(TokioExecutor::new()).build_http::>(); + match tokio::time::timeout(HEALTH_TIMEOUT, client.get(uri)).await { + Ok(Ok(resp)) => resp.status() == hyper::StatusCode::OK, + _ => false, + } +} + +// --------------------------------------------------------------------------- +// Pourcentage de chargement +// --------------------------------------------------------------------------- + +/// `engineLoadPct` : pourcentage estimé de chargement du modèle, ou -1 quand +/// il n'est pas mesurable (pas de PID, /proc illisible, taille inconnue ; et +/// toujours hors Linux). Dossiers de modèles : `models/` et `$LOKI_MODEL_DIRS` +/// — ceux déclarés dans l'UI (base) passent par [`engine_load_pct_dirs`]. +pub fn engine_load_pct(paths: &Paths, cfg: &HashMap) -> i32 { + engine_load_pct_dirs(paths, cfg, &[]) +} + +/// [`engine_load_pct`] avec les dossiers de modèles enregistrés en base +/// (`getJSON(bkState, "model_dirs")` du Go), à passer par l'appelant. +pub fn engine_load_pct_dirs( + paths: &Paths, + cfg: &HashMap, + stored_dirs: &[String], +) -> i32 { + #[cfg(target_os = "linux")] + { + linux::engine_load_pct(paths, cfg, stored_dirs) + } + #[cfg(not(target_os = "linux"))] + { + // Hors Linux, pas de /proc//io : pas mesurable. + let _ = (paths, cfg, stored_dirs); + -1 + } +} + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + + pub fn engine_load_pct( + paths: &Paths, + cfg: &HashMap, + stored_dirs: &[String], + ) -> i32 { + let pid = crate::engine::read_pid(&paths.pid()); + if pid <= 0 { + return -1; + } + let model = cfg.get("MODEL").map(String::as_str).unwrap_or(""); + if model.is_empty() { + return -1; + } + let models = paths.home.join("models"); + let extra = extra_model_dirs(stored_dirs); + let Some(path) = resolve_serve_model_path(model, &models, &extra) else { + return -1; + }; + let size = model_total_size(&path); + if size <= 0 { + return -1; + } + let Ok(io) = std::fs::read_to_string(format!("/proc/{pid}/io")) else { + return -1; + }; + let read = parse_read_bytes(&io); + if read < 0 { + return -1; + } + let rss = std::fs::read_to_string(format!("/proc/{pid}/status")) + .map(|s| parse_vm_rss(&s)) + .unwrap_or(0); + load_pct(read, rss, size) + } +} + +/// Le calcul de `engineLoadPct` : octets lus (`read_bytes`, -1 = inconnu), +/// mémoire résidente et taille du modèle → pourcentage plafonné à 99. +/// +/// Modèle déjà dans le cache disque : read_bytes reste à zéro, mais VmRSS +/// grandit quoi qu'il arrive — on prend le plus avancé des deux. 100 % +/// n'existe pas ici : c'est /health qui dit « prêt ». +pub fn load_pct(read: i64, rss: i64, size: i64) -> i32 { + if read < 0 || size <= 0 { + return -1; + } + let read = read.max(rss); + // Arithmétique 64 bits qui déborde comme celle du Go (inatteignable). + let pct = read.wrapping_mul(100) / size; + pct.clamp(i64::from(i32::MIN), 99) as i32 +} + +/// Ligne `read_bytes:` de `/proc//io` ; -1 si absente. Valeur illisible +/// → 0, débordement → borne (comme `read, _ = strconv.ParseInt(…)`). +pub fn parse_read_bytes(io: &str) -> i64 { + for line in io.split('\n') { + if let Some(v) = line.strip_prefix("read_bytes:") { + return go_parse_int64(v.trim()); + } + } + -1 +} + +/// `procVmRSS` sur le texte de `/proc//status` : « VmRSS: N kB » en +/// octets, 0 si absent ou illisible. +pub fn parse_vm_rss(status: &str) -> i64 { + for line in status.split('\n') { + if let Some(v) = line.strip_prefix("VmRSS:") { + let v = v.trim(); + let v = v.strip_suffix("kB").unwrap_or(v).trim(); + if let Ok(n) = v.parse::() { + return n.wrapping_mul(1024); + } + } + } + 0 +} + +/// `strconv.ParseInt(s, 10, 64)` dont l'erreur est ignorée : 0 sur une +/// syntaxe invalide, la borne sur un débordement. +fn go_parse_int64(s: &str) -> i64 { + use std::num::IntErrorKind; + match s.parse::() { + Ok(n) => n, + Err(e) => match e.kind() { + IntErrorKind::PosOverflow => i64::MAX, + IntErrorKind::NegOverflow => i64::MIN, + _ => 0, + }, + } +} + +// --- Résolution du modèle (port local de backend_model_dirs.go, le temps que +// `crate::paths` le porte en entier) ----------------------------------------- + +/// Séparateur de `$LOKI_MODEL_DIRS` (':' sous Unix, ';' sous Windows). +fn model_dirs_list_sep() -> char { + if cfg!(windows) { ';' } else { ':' } +} + +/// `extraModelDirs` : dossiers enregistrés en base puis `$LOKI_MODEL_DIRS`, +/// vides retirés, rendus absolus. Les doublons ne sont pas retirés : ils ne +/// changent ni « premier dossier qui contient le fichier » ni « chemin dans un +/// des dossiers ». +pub fn extra_model_dirs(stored: &[String]) -> Vec { + let mut raw: Vec = stored.to_vec(); + if let Ok(v) = std::env::var("LOKI_MODEL_DIRS") + && !v.is_empty() + { + raw.extend(v.split(model_dirs_list_sep()).map(str::to_string)); + } + raw.iter() + .map(|d| d.trim()) + .filter(|d| !d.is_empty()) + .filter_map(|d| std::path::absolute(d).ok()) + .collect() +} + +/// `resolveServeModelPath` : MODEL= (nom de fichier ou chemin absolu) → chemin +/// du .gguf à lancer ; `None` = nom invalide ou chemin absolu refusé. +pub fn resolve_serve_model_path(name: &str, models: &Path, extra: &[PathBuf]) -> Option { + let s = name.trim().trim_matches('"').trim(); + if s.is_empty() || !s.to_lowercase().ends_with(".gguf") { + return None; + } + let dirs: Vec<&Path> = std::iter::once(models) + .chain(extra.iter().map(PathBuf::as_path)) + .collect(); + if Path::new(s).is_absolute() { + let abs = clean(s); + // resolveModelPath : dans un des dossiers déclarés, existant ou non. + if dirs.iter().any(|d| path_within(&d.to_string_lossy(), &abs)) { + return Some(PathBuf::from(abs)); + } + // resolveServeModelPath : ailleurs, seulement un fichier qui existe. + return std::fs::metadata(&abs) + .ok() + .filter(|m| !m.is_dir()) + .map(|_| PathBuf::from(abs)); + } + let base = base_name(s); + if base.is_empty() || base == "." { + return None; + } + for d in &dirs { + let p = d.join(base); + if std::fs::metadata(&p).is_ok_and(|m| !m.is_dir()) { + return Some(p); + } + } + Some(models.join(base)) // introuvable : la taille dira -1 +} + +/// `filepath.Clean` (forme Unix ; sous Windows, '\' est d'abord unifié en '/'). +fn clean(p: &str) -> String { + let p = if cfg!(windows) { + p.replace('\\', "/") + } else { + p.to_string() + }; + if p.is_empty() { + return ".".into(); + } + let rooted = p.starts_with('/'); + let mut out: Vec<&str> = Vec::new(); + for seg in p.split('/') { + match seg { + "" | "." => {} + ".." => match out.last() { + Some(&last) if last != ".." => { + out.pop(); + } + _ if !rooted => out.push(".."), + _ => {} + }, + s => out.push(s), + } + } + let body = out.join("/"); + if rooted { + format!("/{body}") + } else if body.is_empty() { + ".".into() + } else { + body + } +} + +/// `normDir` : chemin comparable (Clean, séparateurs unifiés, casse neutralisée +/// sous Windows). +fn norm_dir(p: &str) -> String { + let p = clean(p.trim()).replace('\\', "/"); + if cfg!(windows) { p.to_lowercase() } else { p } +} + +/// `pathWithin` : le fichier `p` est-il dans le dossier `dir` ? +fn path_within(dir: &str, p: &str) -> bool { + let (d, f) = (norm_dir(dir), norm_dir(p)); + f == d || f.starts_with(&format!("{d}/")) +} + +/// `baseName` : nom de fichier en acceptant les DEUX séparateurs. +fn base_name(p: &str) -> &str { + let p = p.trim().trim_end_matches(['/', '\\']); + match p.rfind(['/', '\\']) { + Some(i) => &p[i + 1..], + None => p, + } +} + +/// `shardInfo` : rang (1-based) et nombre total d'une tranche +/// `-00001-of-00003.gguf` ; `None` pour un fichier simple ou une +/// numérotation incohérente. +pub fn shard_info(name: &str) -> Option<(u32, u32)> { + let (_, idx, total, _) = shard_match(base_name(name))?; + let (idx, total): (u32, u32) = (idx.parse().ok()?, total.parse().ok()?); + if idx < 1 || total < 1 || idx > total { + return None; + } + Some((idx, total)) +} + +/// `shardRe` (`(?i)-(\d{2,5})-of-(\d{2,5})\.gguf$`) : (préfixe avant le rang, +/// rang, total, suffixe après le total). +fn shard_match(name: &str) -> Option<(&str, &str, &str, &str)> { + let n = name.len(); + if n < 5 || !name.is_char_boundary(n - 5) || !name[n - 5..].eq_ignore_ascii_case(".gguf") { + return None; + } + let digits_before = |end: usize| { + name.as_bytes()[..end] + .iter() + .rev() + .take_while(|b| b.is_ascii_digit()) + .count() + }; + let t_end = n - 5; + let t_len = digits_before(t_end); + if !(2..=5).contains(&t_len) { + return None; + } + let t_start = t_end - t_len; + if t_start < 4 || !name.as_bytes()[t_start - 4..t_start].eq_ignore_ascii_case(b"-of-") { + return None; + } + let i_end = t_start - 4; + let i_len = digits_before(i_end); + if !(2..=5).contains(&i_len) { + return None; + } + let i_start = i_end - i_len; + if i_start < 1 || name.as_bytes()[i_start - 1] != b'-' { + return None; + } + Some(( + &name[..i_start], + &name[i_start..i_end], + &name[t_start..t_end], + &name[t_end..], + )) +} + +/// `shardFamily` : les noms de TOUTES les tranches de la famille de `name`, en +/// gardant la largeur de la numérotation ; un fichier simple se renvoie +/// lui-même. +pub fn shard_family(name: &str) -> Vec { + let base = base_name(name); + let (Some((_, total)), Some((pre, idx, tot, suf))) = (shard_info(base), shard_match(base)) + else { + return vec![base.to_string()]; + }; + let width = idx.len(); + (1..=total) + .map(|i| format!("{pre}{i:0width$}-of-{tot}{suf}")) + .collect() +} + +/// `shardFamilySize` : somme des tranches PRÉSENTES dans `dir`. +pub fn shard_family_size(dir: &Path, name: &str) -> i64 { + shard_family(name) + .iter() + .filter_map(|n| std::fs::metadata(dir.join(n)).ok()) + .filter(|m| !m.is_dir()) + .map(|m| m.len() as i64) + .sum() +} + +/// `modelTotalSize` : taille du modèle, tranches comprises ; 0 si inconnue. +pub fn model_total_size(path: &Path) -> i64 { + let dir = path.parent().unwrap_or(Path::new("")); + let base = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let total = shard_family_size(dir, &base); + if total > 0 { + return total; + } + std::fs::metadata(path).map(|m| m.len() as i64).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(kv: &[(&str, &str)]) -> HashMap { + kv.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + // --- modelLoadErrorFrom ------------------------------------------------- + + /// TestModelLoadErrorLoadGuard (backend_serve_moe_test.go). Le refus est + /// celui que rend `loadModeRisk` pour `--load-mode none`, modèle 82 Gio, + /// VRAM moteur 16 Gio, RAM 60 Gio. + #[test] + fn model_load_error_load_guard() { + let refuse = "--load-mode none : au moins 66,0 Gio de poids restent en RAM (modèle 82,0 Gio) pour 60,0 Gio de RAM, limite du conteneur comprise — échec certain : swap et décodage effondré, ou processus tué (llama.cpp #26110). --load-mode mmap laisse le système relire les pages au besoin ; LOAD_GUARD=off pour lancer quand même"; + let log = format!( + "llama_model_load: loading model\nmain: model loaded\nserver is listening\n[ERREUR] {refuse}\n" + ); + let got = model_load_error_from(&log); + assert!( + got.contains("LOAD_GUARD=off"), + "refus non signalé : {got:?}" + ); + // Une tentative suivante qui charge efface le refus. + let got = model_load_error_from(&format!("{log}loading model\nmodel loaded\n")); + assert_eq!(got, "", "refus ancien encore signalé"); + } + + /// TestModelLoadErrorStrata (backend_strata_test.go). + #[test] + fn model_load_error_strata() { + let ok = format!( + "{STRATA_SERVE_MARKER} (paquet AJEAN MoE 1.0)\nloading the model (the first start takes a minute or two) ...\nready: http://127.0.0.1:8080/v1 (OpenAI: /v1/chat/completions)\n" + ); + assert_eq!( + model_load_error_from(&ok), + "", + "Strata prêt signalé en échec" + ); + let crash = format!( + "{STRATA_SERVE_MARKER}\nTraceback (most recent call last):\n File \"server.py\", line 1\nRuntimeError: boom\n" + ); + let got = model_load_error_from(&crash); + assert!( + got.contains("Strata"), + "trace Python non signalée : {got:?}" + ); + // une trace d'un lancement PRÉCÉDENT ne compte pas + let got = model_load_error_from(&format!( + "{crash}{STRATA_SERVE_MARKER}\nloading the model ...\n" + )); + assert_eq!(got, "", "ancienne trace prise pour l'échec en cours"); + } + + /// TestModelLoadErrorOOMBeforeBacktrace (backend_strata_test.go). + #[test] + fn model_load_error_oom_before_backtrace() { + let log = "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" + .to_string() + + "4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" + + "/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" + + "/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" + + "/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" + + "/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"; + let got = model_load_error_from(&log); + assert!( + got.contains("mémoire GPU insuffisante"), + "cause mémoire perdue derrière la pile d'appels : {got:?}" + ); + } + + /// Libellés complets (pas de test Go dédié) : les deux formes du message. + #[test] + fn model_load_error_messages_exacts() { + assert_eq!(model_load_error_from(""), ""); + assert_eq!(model_load_error_from("rien à voir\n"), ""); + assert_eq!( + model_load_error_from("loading model\ntensor 'x' has offset 12, expected 16\n"), + "Modèle incompatible avec le moteur : format de quantification non reconnu par ce moteur. Choisis un autre backend (édite le modèle → Moteur)." + ); + assert_eq!( + model_load_error_from("loading model\nUnknown Model Architecture: 'bitnet'\n"), + "Modèle incompatible avec le moteur : type/architecture de modèle inconnu de ce moteur. Choisis un autre backend (édite le modèle → Moteur)." + ); + assert_eq!( + model_load_error_from("loading model\nerror loading model: boom\n"), + "Le modèle n'a pas pu être chargé (échec du chargement du modèle). Ouvre le journal du moteur pour le détail." + ); + // La cause mémoire l'emporte sur l'échec générique écrit avant. + assert_eq!( + model_load_error_from("loading model\nfailed to load model\nout of memory\n"), + "Le modèle n'a pas pu être chargé (mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite, et vérifie qu'aucun autre programme n'occupe la carte (nvidia-smi)). Ouvre le journal du moteur pour le détail." + ); + // Sensible à la casse : « sigsegv » en minuscules n'est pas un crash. + assert_eq!(model_load_error_from("loading model\nsigsegv\n"), ""); + assert_eq!( + model_load_error_from("loading model\nSIGSEGV\n"), + "Le modèle n'a pas pu être chargé (le moteur a planté au démarrage (backtrace dans le journal)). Ouvre le journal du moteur pour le détail." + ); + assert!(is_backtrace_frame("/lib/libc.so.6(abort+0x1)[0x7f]")); + assert!(!is_backtrace_frame("srv load_model: loading model")); + } + + // --- serviceLogTail ----------------------------------------------------- + + #[test] + fn service_log_tail_fichier() { + let dir = tempfile::tempdir().unwrap(); + let paths = Paths { + home: dir.path().to_path_buf(), + service: "loki-engine".into(), + }; + assert_eq!(service_log_tail(&paths, 10), "", "journal absent"); + std::fs::write(paths.log(), "a\nb\nc\n").unwrap(); + assert_eq!(service_log_tail(&paths, 2), "b\nc\n"); + assert_eq!(service_log_tail(&paths, 10), "a\nb\nc\n"); + } + + // --- LLMPort / isExternalConfig ----------------------------------------- + + /// La partie LLMPort de TestStore… (web_internal_test.go : PORT=9000). + #[test] + fn port_du_moteur() { + assert_eq!(llm_port(&cfg(&[("PORT", "9000")])), 9000); + assert_eq!(llm_port(&cfg(&[])), 8080); + for bad in ["", "abc", "0", "-1", " 9000", "70000"] { + assert_eq!(llm_port(&cfg(&[("PORT", bad)])), 8080, "PORT={bad:?}"); + } + assert_eq!( + llm_port(&cfg(&[("PORT", "+8081")])), + 8081, + "Atoi accepte le +" + ); + } + + /// TestReconnaissanceDunPresetExterne (backend_external_test.go). + #[test] + fn reconnaissance_dun_preset_externe() { + assert!( + is_external(&cfg(&[(EXT_KEY_FLAG, "1")])), + "EXTERNAL=1 non reconnu" + ); + for v in ["", "0", "off", "false"] { + assert!( + !is_external(&cfg(&[(EXT_KEY_FLAG, v)])), + "EXTERNAL={v:?} pris pour un preset externe" + ); + } + // parseEnv("MODEL=\"m.gguf\"\nCTX=4096\n") + assert!( + !is_external(&cfg(&[("MODEL", "m.gguf"), ("CTX", "4096")])), + "un preset local est pris pour un externe" + ); + assert!(is_external(&cfg(&[(EXT_KEY_FLAG, " 1 ")]))); + } + + // --- engineLoadPct ------------------------------------------------------ + + #[test] + fn calcul_du_pourcentage() { + assert_eq!(load_pct(-1, 1 << 30, 100), -1, "read_bytes absent"); + assert_eq!(load_pct(50, 0, 200), 25); + assert_eq!(load_pct(0, 150, 200), 75, "cache disque : VmRSS l'emporte"); + assert_eq!(load_pct(500, 0, 200), 99, "jamais 100 %"); + assert_eq!(load_pct(0, 0, 0), -1); + + let io = "rchar: 1\nwchar: 2\nread_bytes: 4096\nwrite_bytes: 0\n"; + assert_eq!(parse_read_bytes(io), 4096); + assert_eq!(parse_read_bytes("rchar: 1\n"), -1); + assert_eq!(parse_read_bytes("read_bytes: x\n"), 0); + let st = "Name:\tllama-server\nVmPeak:\t 9 kB\nVmRSS:\t 2048 kB\n"; + assert_eq!(parse_vm_rss(st), 2048 * 1024); + assert_eq!(parse_vm_rss("VmRSS: x kB\nVmRSS: 3 kB\n"), 3 * 1024); + assert_eq!(parse_vm_rss("Name: x\n"), 0); + } + + #[test] + fn hors_mesure_sans_pid() { + let dir = tempfile::tempdir().unwrap(); + let paths = Paths { + home: dir.path().to_path_buf(), + service: "loki-engine".into(), + }; + assert_eq!(engine_load_pct(&paths, &cfg(&[("MODEL", "m.gguf")])), -1); + } + + /// Le process de test lui-même tient lieu de moteur : il a lu plus d'un + /// octet, un modèle d'un octet est donc « chargé » à 99 %. + #[cfg(target_os = "linux")] + #[test] + fn pourcentage_sur_le_process_courant() { + let dir = tempfile::tempdir().unwrap(); + let paths = Paths { + home: dir.path().to_path_buf(), + service: "loki-engine".into(), + }; + std::fs::create_dir_all(dir.path().join("models")).unwrap(); + std::fs::write(dir.path().join("models/m.gguf"), b"x").unwrap(); + std::fs::write(paths.pid(), std::process::id().to_string()).unwrap(); + assert_eq!(engine_load_pct(&paths, &cfg(&[("MODEL", "m.gguf")])), 99); + assert_eq!(engine_load_pct(&paths, &cfg(&[])), -1, "MODEL vide"); + assert_eq!( + engine_load_pct(&paths, &cfg(&[("MODEL", "absent.gguf")])), + -1 + ); + } + + #[test] + fn resolution_du_modele() { + let dir = tempfile::tempdir().unwrap(); + let models = dir.path().join("models"); + let extra = dir.path().join("ailleurs"); + std::fs::create_dir_all(&models).unwrap(); + std::fs::create_dir_all(&extra).unwrap(); + std::fs::write(extra.join("x.gguf"), b"12345").unwrap(); + let extras = vec![extra.clone()]; + assert_eq!( + resolve_serve_model_path(" \"x.gguf\" ", &models, &extras), + Some(extra.join("x.gguf")) + ); + assert_eq!( + resolve_serve_model_path("y.gguf", &models, &extras), + Some(models.join("y.gguf")), + "introuvable : models/ par défaut" + ); + assert_eq!(resolve_serve_model_path("y.bin", &models, &extras), None); + assert_eq!(resolve_serve_model_path(" ", &models, &extras), None); + assert_eq!(model_total_size(&extra.join("x.gguf")), 5); + assert_eq!(model_total_size(&models.join("y.gguf")), 0); + } + + /// TestShardInfo (backend_models_shards_test.go). + #[test] + fn shard_info_cas() { + let cases: [(&str, Option<(u32, u32)>); 6] = [ + ("Laguna-S-2.1-UD-IQ4_XS-00001-of-00003.gguf", Some((1, 3))), + ("Laguna-S-2.1-UD-IQ4_XS-00002-of-00003.gguf", Some((2, 3))), + ("m-01-of-03.gguf", Some((1, 3))), + ("Qwen2.5-7B-Instruct-Q4_K_M.gguf", None), + ("bidon-00004-of-00003.gguf", None), // numérotation incohérente + ("pas-un-gguf-00001-of-00002.bin", None), + ]; + for (name, want) in cases { + assert_eq!(shard_info(name), want, "shard_info({name:?})"); + } + } + + /// TestShardFamilyGardeLaLargeur (backend_models_shards_test.go). + #[test] + fn shard_family_garde_la_largeur() { + assert_eq!( + shard_family("Laguna-00002-of-00003.gguf"), + [ + "Laguna-00001-of-00003.gguf", + "Laguna-00002-of-00003.gguf", + "Laguna-00003-of-00003.gguf", + ] + ); + assert_eq!(shard_family("simple.gguf"), ["simple.gguf"]); + } + + /// TestShardFamilyTailleEtManquantes, partie taille + /// (backend_models_shards_test.go) ; et modelTotalSize sur la famille. + #[test] + fn shard_family_taille() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("m-00001-of-00003.gguf"), vec![0u8; 100]).unwrap(); + std::fs::write(dir.path().join("m-00003-of-00003.gguf"), vec![0u8; 300]).unwrap(); + assert_eq!(shard_family_size(dir.path(), "m-00001-of-00003.gguf"), 400); + assert_eq!( + model_total_size(&dir.path().join("m-00001-of-00003.gguf")), + 400 + ); + } + + #[test] + fn chemins_comme_go() { + assert_eq!(clean("/a/b/../c/./d/"), "/a/c/d"); + assert_eq!(clean("/../a"), "/a"); + assert_eq!(clean("a/../../b"), "../b"); + assert_eq!(clean(""), "."); + assert!(path_within("/data/models", "/data/models/x.gguf")); + assert!(path_within("/data/models/", "/data/models/sub/../x.gguf")); + assert!(!path_within("/data/models", "/data/models2/x.gguf")); + assert_eq!(base_name(r"C:\models\x.gguf"), "x.gguf"); + assert_eq!(base_name("/m/x.gguf/ "), "x.gguf"); + } + + // --- healthCheck -------------------------------------------------------- + + /// Serveur local qui répond `status` sur /health (404 ailleurs). + async fn serveur(status: u16) -> u16 { + use bytes::Bytes; + use http_body_util::Full; + use hyper::body::Incoming; + use hyper::{Request, Response}; + use hyper_util::rt::TokioIo; + + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((s, _)) = l.accept().await { + tokio::spawn(async move { + let svc = + hyper::service::service_fn(move |req: Request| async move { + let code = if req.uri().path() == "/health" { + status + } else { + 404 + }; + Ok::<_, std::convert::Infallible>( + Response::builder() + .status(code) + .body(Full::new(Bytes::from_static(b"{\"status\":\"ok\"}"))) + .unwrap(), + ) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(s), svc) + .await; + }); + } + }); + port + } + + #[tokio::test] + async fn sonde_health() { + let ok = serveur(200).await; + assert!( + health_check(&cfg(&[("PORT", &ok.to_string())])).await, + "200 = prêt" + ); + let loading = serveur(503).await; + assert!( + !health_check(&cfg(&[("PORT", &loading.to_string())])).await, + "503 = chargement" + ); + // Port fermé : on réserve un port puis on le libère. + let closed = { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() + }; + let c = cfg(&[("PORT", &closed.to_string())]); + assert!(!health_check(&c).await, "port fermé"); + // Preset externe : prêt sans sonder, même moteur éteint. + let mut ext = c.clone(); + ext.insert(EXT_KEY_FLAG.into(), "1".into()); + assert!(health_check(&ext).await); + assert!( + !health_check(&cfg(&[("PORT", "70000")])).await, + "port invalide" + ); + } + + /// Un moteur qui accepte la connexion sans jamais répondre : la sonde rend + /// la main au bout de 3 s. + #[tokio::test] + async fn sonde_health_bornee() { + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((s, _)) = l.accept().await { + held.push(s); + } + }); + let t0 = std::time::Instant::now(); + assert!(!health_check(&cfg(&[("PORT", &port.to_string())])).await); + assert!(t0.elapsed() < Duration::from_secs(5)); + } +}