mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-15 11:19:07 +02:00
Lot 4 : état du moteur porté en Rust (engstatus)
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/<pid>/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 <noreply@anthropic.com>
This commit is contained in:
1 parent
b98ee5c3e7
commit
0d75e90386
1 file changed
+960
-1
@@ -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/<pid>/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/<service>.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<String, String>) -> i64 {
|
||||
if let Some(p) = cfg.get("PORT")
|
||||
&& let Ok(n) = p.parse::<i64>()
|
||||
&& 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<String, String>) -> 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<String, String>) -> 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:<port>/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<String, String>) -> 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::<hyper::Uri>() else {
|
||||
return false;
|
||||
};
|
||||
let client = Client::builder(TokioExecutor::new()).build_http::<Empty<Bytes>>();
|
||||
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<String, String>) -> 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<String, String>,
|
||||
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/<pid>/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<String, String>,
|
||||
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/<pid>/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/<pid>/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::<i64>() {
|
||||
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::<i64>() {
|
||||
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<PathBuf> {
|
||||
let mut raw: Vec<String> = 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<PathBuf> {
|
||||
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
|
||||
/// `<base>-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<String> {
|
||||
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<String, String> {
|
||||
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<Incoming>| 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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user