fix(model-server): deadline de warmup configurable pour le cold-start llama.cpp (#55)

Corrige la régression high « Error on loading local model » : le premier chargement
du serveur modèle local (cold-start llama.cpp) dépassait la fenêtre de readiness et
échouait. Le warmup dispose désormais d'un deadline par défaut de 600 s, surchargable
par config optionnelle `warmup_deadline_secs` (validée dans [30, 1800]).

- domain: champ `warmup_deadline_secs: Option<u64>` + validation de borne
- application: policy de readiness effective (défaut 600 s, override par config)
- app-tauri: DTO `warmupDeadlineSecs`
- infrastructure: application de la deadline effective au warmup

Verdict QA (vert) : domain 252, application 81 + 22 model_server, app-tauri
dto_model_servers 6, infrastructure model_server 2, build OK. Contrat readiness
couvert sur ports mockés.
Caveat : le cold-start end-to-end réel (llama-server) sort du sandbox de test ->
vérification manuelle utilisateur restante, non couverte par les tests unitaires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:41:44 +02:00
parent ce5aa2811c
commit f7cae4c0a8
7 changed files with 200 additions and 7 deletions

View File

@ -162,7 +162,7 @@ impl Default for ReadinessPolicy {
Self {
attempts: 20,
backoff: Duration::from_millis(250),
warmup_deadline: Duration::from_secs(120),
warmup_deadline: Duration::from_secs(600),
}
}
}
@ -261,6 +261,15 @@ impl EnsureLocalModelServer {
self
}
/// Returns the readiness policy effective for a persisted server config.
///
/// The config may override only the warmup deadline; probe cadence remains
/// owned by the application policy.
#[must_use]
pub fn effective_readiness_policy(&self, config: &LocalModelServerConfig) -> ReadinessPolicy {
self.readiness_for(config)
}
/// Overrides the long Hugging Face download/preparation deadline.
#[must_use]
pub fn with_hf_download_deadline(mut self, deadline: Duration) -> Self {
@ -440,8 +449,9 @@ impl EnsureLocalModelServer {
handle: &ManagedProcessHandle,
hf_source: Option<String>,
) -> Result<EnsureLocalModelServerOutput, AppError> {
let readiness = self.readiness_for(config);
let mut attempts = 0usize;
let deadline = Instant::now() + self.readiness.warmup_deadline;
let deadline = Instant::now() + readiness.warmup_deadline;
loop {
match self.probe.probe(&config.endpoint).await {
Err(err) => {
@ -462,7 +472,7 @@ impl EnsureLocalModelServer {
match self.process.status(handle).await {
Ok(ProcessStatus::Running) => {
if attempts.saturating_add(1) == self.readiness.attempts {
if attempts.saturating_add(1) == readiness.attempts {
if let Some(source) = hf_source.as_ref() {
self.publish(
config.id,
@ -496,14 +506,24 @@ impl EnsureLocalModelServer {
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;
if !readiness.backoff.is_zero() {
tokio::time::sleep(readiness.backoff).await;
} else {
tokio::task::yield_now().await;
}
}
}
fn readiness_for(&self, config: &LocalModelServerConfig) -> ReadinessPolicy {
ReadinessPolicy {
warmup_deadline: config
.warmup_deadline_secs
.map(Duration::from_secs)
.unwrap_or(self.readiness.warmup_deadline),
..self.readiness
}
}
/// Stops active servers whose policy is [`StopPolicy::StopOnAppExit`].
///
/// # Errors

View File

@ -435,6 +435,43 @@ fn progress(downloaded: Option<u64>, total: Option<u64>) -> ModelArtifactProgres
}
}
#[test]
fn readiness_policy_default_warmup_deadline_is_ten_minutes() {
assert_eq!(
ModelServerReadinessPolicy::default().warmup_deadline,
Duration::from_secs(600)
);
}
#[test]
fn effective_readiness_policy_uses_configured_warmup_deadline_when_present() {
let configured = config(sid(23), 8103, "/models/qwen.gguf", true)
.with_warmup_deadline_secs(Some(900))
.unwrap();
let unconfigured = config(sid(24), 8104, "/models/qwen.gguf", true);
let usecase = EnsureLocalModelServer::new(
Arc::new(FakeRegistry::default()),
Arc::new(FakeProbe::new(Vec::new())),
Arc::new(FakeProcess::default()),
Arc::new(FakeRuntime),
Arc::new(FakeFs::default()),
Arc::new(FakeEvents::default()),
);
assert_eq!(
usecase
.effective_readiness_policy(&configured)
.warmup_deadline,
Duration::from_secs(900)
);
assert_eq!(
usecase
.effective_readiness_policy(&unconfigured)
.warmup_deadline,
Duration::from_secs(600)
);
}
#[tokio::test]
async fn reachable_server_is_reused_without_spawn() {
let registry = Arc::new(FakeRegistry::default());