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:
@ -158,11 +158,15 @@ struct FakeProcess {
|
||||
spawns: Mutex<Vec<SpawnSpec>>,
|
||||
kills: Mutex<Vec<String>>,
|
||||
statuses: Mutex<HashMap<String, ProcessStatus>>,
|
||||
spawn_delay: Duration,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManagedProcess for FakeProcess {
|
||||
async fn spawn(&self, spec: SpawnSpec) -> Result<ManagedProcessHandle, ModelServerError> {
|
||||
if !self.spawn_delay.is_zero() {
|
||||
tokio::time::sleep(self.spawn_delay).await;
|
||||
}
|
||||
self.spawns.lock().unwrap().push(spec);
|
||||
let handle = ManagedProcessHandle {
|
||||
id: format!("h{}", self.spawns.lock().unwrap().len()),
|
||||
@ -373,6 +377,73 @@ async fn absent_auto_start_spawns_and_waits_until_ready() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_ensure_same_server_shares_one_start_attempt() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
registry
|
||||
.save(config(sid(10), 8090, "/models/qwen.gguf", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.existing
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("/models/qwen.gguf".to_owned());
|
||||
let process = Arc::new(FakeProcess {
|
||||
spawn_delay: Duration::from_millis(25),
|
||||
..FakeProcess::default()
|
||||
});
|
||||
let events = Arc::new(FakeEvents::default());
|
||||
let usecase = Arc::new(ensure(
|
||||
Arc::clone(®istry),
|
||||
Arc::new(FakeProbe::new(vec![
|
||||
ModelServerStatus::Unreachable,
|
||||
ModelServerStatus::ReadyReused,
|
||||
])),
|
||||
Arc::clone(&process),
|
||||
fs,
|
||||
Arc::clone(&events),
|
||||
));
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..5 {
|
||||
let usecase = Arc::clone(&usecase);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
usecase
|
||||
.execute(EnsureLocalModelServerInput { server_id: sid(10) })
|
||||
.await
|
||||
.unwrap()
|
||||
}));
|
||||
}
|
||||
|
||||
let mut outputs = Vec::new();
|
||||
for task in tasks {
|
||||
outputs.push(task.await.unwrap());
|
||||
}
|
||||
|
||||
assert!(outputs
|
||||
.iter()
|
||||
.all(|out| out.ready.status == ModelServerStatus::ReadyStarted));
|
||||
assert_eq!(process.spawns.lock().unwrap().len(), 1);
|
||||
|
||||
let starting_events = events
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
matches!(
|
||||
event,
|
||||
DomainEvent::ModelServerStatusChanged {
|
||||
status: domain::model_server::ModelServerLifecycleStatus::Starting,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(starting_events, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_model_path_is_path_not_accessible() {
|
||||
let registry = Arc::new(FakeRegistry::default());
|
||||
|
||||
Reference in New Issue
Block a user