fix(model-server): singleflight au démarrage du serveur de modèle local (#45)
Corrige la race « port_occupied:8080 » au démarrage OpenCode/model server : plusieurs demandes concurrentes tentaient chacune de lancer le serveur de modèle local, provoquant un conflit de port. Le démarrage est désormais sérialisé en singleflight — une seule tentative de lancement partagée entre les appelants concurrents. Couvert par un nouveau test de concurrence (cargo test -p application vert : 81 unit + 9 model_server). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -14,6 +14,7 @@ use domain::ports::{
|
||||
ModelServerRegistry, ModelServerRuntime, ProcessStatus, ProfileStore, RemotePath,
|
||||
};
|
||||
use domain::{LocalModelServerId, StopPolicy};
|
||||
use tokio::sync::{Mutex as AsyncMutex, Notify};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -167,6 +168,35 @@ struct ActiveServer {
|
||||
stop_policy: StopPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InflightEnsure {
|
||||
result: Mutex<Option<Result<EnsureLocalModelServerOutput, AppError>>>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
impl InflightEnsure {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
result: Mutex::new(None),
|
||||
notify: Notify::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait(&self) -> Result<EnsureLocalModelServerOutput, AppError> {
|
||||
loop {
|
||||
if let Some(result) = self.result.lock().unwrap().clone() {
|
||||
return result;
|
||||
}
|
||||
self.notify.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn complete(&self, result: Result<EnsureLocalModelServerOutput, AppError>) {
|
||||
*self.result.lock().unwrap() = Some(result);
|
||||
self.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures a configured local model server is reachable, starting it when allowed.
|
||||
pub struct EnsureLocalModelServer {
|
||||
registry: Arc<dyn ModelServerRegistry>,
|
||||
@ -176,6 +206,7 @@ pub struct EnsureLocalModelServer {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
events: Arc<dyn EventBus>,
|
||||
active: Mutex<HashMap<LocalModelServerId, ActiveServer>>,
|
||||
inflight: AsyncMutex<HashMap<LocalModelServerId, Arc<InflightEnsure>>>,
|
||||
readiness: ReadinessPolicy,
|
||||
}
|
||||
|
||||
@ -199,6 +230,7 @@ impl EnsureLocalModelServer {
|
||||
fs,
|
||||
events,
|
||||
active: Mutex::new(HashMap::new()),
|
||||
inflight: AsyncMutex::new(HashMap::new()),
|
||||
readiness: ReadinessPolicy::default(),
|
||||
}
|
||||
}
|
||||
@ -217,6 +249,38 @@ impl EnsureLocalModelServer {
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: EnsureLocalModelServerInput,
|
||||
) -> Result<EnsureLocalModelServerOutput, AppError> {
|
||||
let server_id = input.server_id;
|
||||
let (flight, is_leader) = {
|
||||
let mut inflight = self.inflight.lock().await;
|
||||
if let Some(flight) = inflight.get(&server_id) {
|
||||
(Arc::clone(flight), false)
|
||||
} else {
|
||||
let flight = Arc::new(InflightEnsure::new());
|
||||
inflight.insert(server_id, Arc::clone(&flight));
|
||||
(flight, true)
|
||||
}
|
||||
};
|
||||
|
||||
if !is_leader {
|
||||
return flight.wait().await;
|
||||
}
|
||||
|
||||
let result = self.execute_inner(input).await;
|
||||
flight.complete(result.clone());
|
||||
let mut inflight = self.inflight.lock().await;
|
||||
if inflight
|
||||
.get(&server_id)
|
||||
.is_some_and(|current| Arc::ptr_eq(current, &flight))
|
||||
{
|
||||
inflight.remove(&server_id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn execute_inner(
|
||||
&self,
|
||||
input: EnsureLocalModelServerInput,
|
||||
) -> Result<EnsureLocalModelServerOutput, AppError> {
|
||||
let config = self
|
||||
.registry
|
||||
|
||||
Reference in New Issue
Block a user