fix(model-server): fiabiliser la readiness au démarrage d'un serveur modèle local (#55)

Le serveur modèle local (llama.cpp) était tué après ~5 s par un timeout de
readiness prématuré, alors que le modèle était encore en warmup (chargement en
RAM/VRAM). Résultat : « Error on loading local model » alors que le process
était vivant et en train de démarrer normalement.

Introduit une deadline de warmup longue configurable (~120 s via
ReadinessPolicy) qui distingue un process « vivant en warmup » d'un process
« mort » :
- Ready                → succès immédiat
- Unreachable + Running → continuer d'attendre (warmup en cours)
- Unreachable + Exited  → échec rapide (process mort, inutile d'attendre)
- deadline atteinte     → stop + Timeout

Tests (crates/application/tests/model_server.rs) : warmup lent, exit pendant le
warmup, deadline atteinte, plus la régression adaptée. 20/20 verts, crate
application verte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 18:43:46 +02:00
parent e0ee24e3b5
commit 141c13dabb
2 changed files with 165 additions and 66 deletions

View File

@ -153,6 +153,8 @@ pub struct ReadinessPolicy {
pub attempts: usize,
/// Delay between attempts.
pub backoff: Duration,
/// Maximum time to wait for an auto-started process to expose its endpoint.
pub warmup_deadline: Duration,
}
impl Default for ReadinessPolicy {
@ -160,6 +162,7 @@ impl Default for ReadinessPolicy {
Self {
attempts: 20,
backoff: Duration::from_millis(250),
warmup_deadline: Duration::from_secs(120),
}
}
}
@ -437,68 +440,9 @@ impl EnsureLocalModelServer {
handle: &ManagedProcessHandle,
hf_source: Option<String>,
) -> Result<EnsureLocalModelServerOutput, AppError> {
for attempt in 0..self.readiness.attempts {
match self.probe.probe(&config.endpoint).await {
Err(err) => {
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
}
Ok(ModelServerStatus::ReadyReused | ModelServerStatus::ReadyStarted) => {
self.publish(
config.id,
ModelServerLifecycleStatus::Ready { reused: false },
);
return Ok(EnsureLocalModelServerOutput {
ready: ready(config, ModelServerStatus::ReadyStarted),
});
}
Ok(ModelServerStatus::Unreachable) => {
if attempt + 1 < self.readiness.attempts && !self.readiness.backoff.is_zero() {
tokio::time::sleep(self.readiness.backoff).await;
}
}
}
}
let Some(source) = hf_source else {
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
};
match self.process.status(handle).await {
Ok(ProcessStatus::Running) => {
self.publish(
config.id,
ModelServerLifecycleStatus::Downloading {
downloaded_bytes: None,
total_bytes: None,
percent: None,
source: Some(source.clone()),
},
);
}
Ok(ProcessStatus::Exited { code }) => {
self.active.lock().unwrap().remove(&config.id);
return self.fail(config.id, premature_exit_error(code));
}
Ok(ProcessStatus::Unknown) => {
self.active.lock().unwrap().remove(&config.id);
return self.fail(
config.id,
ModelServerError::Process("process status unknown".to_owned()),
);
}
Err(err) => return self.fail(config.id, err),
}
let deadline = Instant::now() + self.hf_download_deadline;
let mut attempts = 0usize;
let deadline = Instant::now() + self.readiness.warmup_deadline;
loop {
if Instant::now() >= deadline {
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
}
match self.probe.probe(&config.endpoint).await {
Err(err) => {
self.stop_started_server(config.id, handle).await;
@ -515,12 +459,21 @@ impl EnsureLocalModelServer {
}
Ok(ModelServerStatus::Unreachable) => {}
}
match self.process.status(handle).await {
Ok(ProcessStatus::Running) => {
if !self.readiness.backoff.is_zero() {
tokio::time::sleep(self.readiness.backoff).await;
} else {
tokio::task::yield_now().await;
if attempts.saturating_add(1) == self.readiness.attempts {
if let Some(source) = hf_source.as_ref() {
self.publish(
config.id,
ModelServerLifecycleStatus::Downloading {
downloaded_bytes: None,
total_bytes: None,
percent: None,
source: Some(source.clone()),
},
);
}
}
}
Ok(ProcessStatus::Exited { code }) => {
@ -536,6 +489,18 @@ impl EnsureLocalModelServer {
}
Err(err) => return self.fail(config.id, err),
}
attempts = attempts.saturating_add(1);
if Instant::now() >= deadline {
let err = ModelServerError::Timeout;
self.stop_started_server(config.id, handle).await;
return self.fail(config.id, err);
}
if !self.readiness.backoff.is_zero() {
tokio::time::sleep(self.readiness.backoff).await;
} else {
tokio::task::yield_now().await;
}
}
}