Fix deadlock between endpoint and video status queries

This commit is contained in:
Mikei386
2026-09-30 22:44:06 +02:00
parent a603a223c4
commit 5c458bff06
3 changed files with 28 additions and 1 deletions
+11
View File
@@ -27,3 +27,14 @@ Die Ladypoly-WebUI ist hiermit noch nicht verbunden. Der Deck-Testbereich und di
## Exklusiver Musik-Modus ## 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. Ü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.
+4 -1
View File
@@ -62,7 +62,10 @@ class Endpoint:
subset=[p for p in rows if p['kind']==kind] 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)) 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() 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): 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.') 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'] port=data['port']
+13
View File
@@ -61,6 +61,19 @@ class EndpointTests(unittest.TestCase):
def request(self,path='/v1/models',data=None,token='A'*32): 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=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)) 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): 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.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' 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'