diff --git a/docs/MUSIC_API.md b/docs/MUSIC_API.md index 3c7cf4e..bed4417 100644 --- a/docs/MUSIC_API.md +++ b/docs/MUSIC_API.md @@ -27,3 +27,14 @@ Die Ladypoly-WebUI ist hiermit noch nicht verbunden. Der Deck-Testbereich und di ## Exklusiver Musik-Modus Übersicht → GPU-Modus bietet LLM, Video und Musik. Musik setzt mindestens ein am Endpunkt freigegebenes ausführbares Musikprofil voraus. Beim Wechsel werden eigene Deck-GPU-Worker beendet und laufende GPU-Reservierungen freigegeben; fremde Videodienste werden geprüft, aber nie gestoppt. Musikgewichte laden weiterhin erst bei Anfrage. Im Musikmodus werden Chat/Bild/TTS/STT blockiert; für diese Aufträge auf LLM zurückschalten. Musik → Testen zeigt einen Umschalthinweis, solange ein anderer Modus aktiv ist. + +## Statusabfragen und parallele GUI-Zugriffe + +Endpunkt- und Video-Status können gleichzeitig von Übersicht, Bibliothek und +Profilen angefragt werden. Der Video-Status liest auch die freigegebenen +Musikprofile. Deshalb darf der Endpunkt seine eigene Sperre nicht halten, +während er den Video-Status abfragt: die umgekehrte Reihenfolge würde beide +Abfragen gegenseitig blockieren. Ein Regressionstest prüft den gleichzeitigen +Profilzugriff während der Video-Statusabfrage. Der Fehler ließ vorhandene +Bibliotheken leer erscheinen; Gewichte, Downloadhistorie und Profile wurden +dabei nicht verändert. diff --git a/endpoint.py b/endpoint.py index f5c1582..2ecc9c4 100644 --- a/endpoint.py +++ b/endpoint.py @@ -62,7 +62,10 @@ class Endpoint: subset=[p for p in rows if p['kind']==kind] counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(self.tts and self.tts.status().get('loaded')) if kind=='audio' else bool(self.stt and self.stt.status().get('loaded')) if kind=='stt' else bool(self.music and self.music.status().get('loaded')) if kind=='music' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image') or (kind=='audio' and self.tts is not None) or (kind=='stt' and self.stt is not None) or (kind=='music' and self.music is not None)) scheduler_status=self.scheduler.status() - with self.lock:return dict(video=self.video.status() if self.video else None,state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight) + # Video status can call back into endpoint.rows(); never hold our lock + # while acquiring another service's lock. Concurrent GUI requests otherwise deadlock. + video_status=self.video.status() if self.video else None + with self.lock:return dict(video=video_status,state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight) def configure(self,data): if set(data)!={'port'} or type(data['port']) is not int or not 1024<=data['port']<=65535:raise ValueError('Port zwischen 1024 und 65535 erforderlich.') port=data['port'] diff --git a/test_endpoint.py b/test_endpoint.py index 550bfa9..8b0ab0b 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -61,6 +61,19 @@ class EndpointTests(unittest.TestCase): def request(self,path='/v1/models',data=None,token='A'*32): conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5);headers={'Content-Type':'application/json','Authorization':'Bearer '+token} conn.request('POST' if data is not None else 'GET',path,body=json.dumps(data) if data is not None else None,headers=headers);r=conn.getresponse();body=r.read();conn.close();return r.status,(body if data and data.get('stream') else json.loads(body)) + def test_status_allows_video_callback_to_read_endpoint_from_other_thread(self): + finished=threading.Event();readers=[] + def video_status(): + def read_profiles(): + self.ep.rows();finished.set() + reader=threading.Thread(target=read_profiles,daemon=True);readers.append(reader);reader.start() + self.assertTrue(finished.wait(1),'Endpoint lock blocks concurrent video/profile status queries') + return {'service':{}} + self.ep.video=Mock();self.ep.video.status.side_effect=video_status + try:self.ep.status() + finally: + self.ep.video=None + for reader in readers:reader.join(2) def test_music_discovery_and_authenticated_wav_route(self): self.rows.append(dict(id='music',name='Music',kind='music',runnable=True,blockers=[],parameters={},updated_at=1)) self.ep.music=Mock();self.ep.music.start.return_value=dict(id='a'*32);self.ep.music.status.return_value=dict(job=dict(id='a'*32,state='complete'));self.ep.music.audio.return_value=b'RIFFsynthetic-WAV'