""" Plattformunabhaengige Prozessverwaltung fuer die Web-Testinstanz (ersetzt die fruehere PowerShell-Loesung run_web_helper.ps1, damit dasselbe Skript unter Windows und Linux funktioniert). Aufruf (ueber bin/run_web.bat bzw. bin/run_web.sh oder direkt): python libs/webserver.py start # Server im Hintergrund starten python libs/webserver.py stop # Server beenden python libs/webserver.py status # laeuft er? PID? Port? python libs/webserver.py run # im Vordergrund (Strg+C beendet) python libs/webserver.py # wie "run" --port N weicht vom Standardport 8000 ab Designentscheidung gegen Zombie-Prozesse: Im Hintergrundbetrieb ("start") laeuft uvicorn OHNE --reload als ein einzelner Prozess, der per PID-Datei sauber beendet werden kann. Der Auto-Reload (--reload) startet Arbeitsprozesse als multiprocessing-Spawn-Kinder, die bei unsanftem Ende des Reloaders MIT geerbtem Server-Socket weiterleben und den Port unsichtbar weiterbedienen koennen (unter Windows tauchen sie weder als Socket-Besitzer noch mit "uvicorn" in der Kommandozeile auf). Deshalb gibt es --reload nur im Vordergrundmodus ("run"), wo Strg+C alles gemeinsam beendet. """ __author__ = "Michael Stangl" __copyright__ = "Copyright (c) 2026 Michael Stangl" __license__ = "Python" import argparse import json import os import signal import socket import subprocess import sys import time import urllib.request BASISDIR = os.path.dirname( os.path.dirname( os.path.abspath( __file__ ) ) ) WEBDIR = os.path.join( BASISDIR, 'web' ) LOGDIR = os.path.join( BASISDIR, 'log' ) PIDDATEI = os.path.join( LOGDIR, 'run_web.pid' ) LOGDATEI = os.path.join( LOGDIR, 'run_web.log' ) HOST = '127.0.0.1' STANDARDPORT = 8000 APPTITEL = 'Sitz & Platz' # steht im OpenAPI-Titel von web/main.py # ---------------------------------------------------------------- Sonden def PortOffen(Port): """True, wenn auf dem Port etwas Verbindungen annimmt""" with socket.socket( socket.AF_INET, socket.SOCK_STREAM ) as s: s.settimeout( 1.0 ) return s.connect_ex( (HOST, Port) ) == 0 def IstUnsereApp(Port): """True/False, ob auf dem Port unsere FastAPI-App antwortet; None, wenn keine (verwertbare) Antwort kommt""" try: with urllib.request.urlopen( 'http://%s:%d/openapi.json' % (HOST, Port), timeout=3 ) as antwort: daten = json.load( antwort ) return APPTITEL in daten.get( 'info', {} ).get( 'title', '' ) except Exception: return None def PidLebt(Pid): """True, wenn der Prozess existiert (portabel)""" if Pid is None or Pid <= 0: return False if sys.platform == 'win32': # os.kill(pid, 0) ist unter Windows unbrauchbar (wirft WinError 87 # statt zu pruefen). Stattdessen im Toolhelp-Snapshot nachsehen. return Pid in _ProzessTabelle() try: os.kill( Pid, 0 ) return True except PermissionError: return True # existiert, gehoert aber jemand anderem except OSError: return False # ------------------------------------------------------------- PID-Datei def PidLesen(): try: with open( PIDDATEI ) as f: return int( f.read().strip() ) except (OSError, ValueError): return None def PidSchreiben(Pid): os.makedirs( LOGDIR, exist_ok=True ) with open( PIDDATEI, 'w' ) as f: f.write( '%d\n' % Pid ) def PidLoeschen(): try: os.remove( PIDDATEI ) except OSError: pass def ListenerPid(Port): """PID des Prozesses, der auf dem Port lauscht (Fallback, wenn die PID-Datei fehlt); None wenn nicht feststellbar""" try: if sys.platform == 'win32': ausgabe = subprocess.run( ['netstat', '-ano', '-p', 'tcp'], capture_output=True, text=True, timeout=10 ).stdout for zeile in ausgabe.splitlines(): teile = zeile.split() # Zustandsspalte NICHT auf "LISTENING" pruefen - netstat # ist lokalisiert (deutsch: "ABHOEREN"). Ein Listener ist # an der Gegenstelle 0.0.0.0:0 erkennbar; PID = letzte Spalte. if ( len(teile) >= 5 and teile[0] == 'TCP' and teile[1].endswith( ':%d' % Port ) and teile[2] in ('0.0.0.0:0', '[::]:0') ): return int( teile[-1] ) else: ausgabe = subprocess.run( ['ss', '-tlnp'], capture_output=True, text=True, timeout=10 ).stdout for zeile in ausgabe.splitlines(): if ':%d ' % Port in zeile and 'pid=' in zeile: return int( zeile.split('pid=')[1].split(',')[0] ) except (OSError, ValueError, subprocess.SubprocessError): pass return None # ------------------------------------------------- Prozessbaum-Hilfen def _ProzessTabelle(): """{ Pid: (ElternPid, Prozessname) } aller laufenden Prozesse - Windows ueber einen Toolhelp-Snapshot (ctypes, Standardbibliothek), Linux ueber /proc""" tabelle = {} if sys.platform == 'win32': import ctypes import ctypes.wintypes as wt class PROCESSENTRY32(ctypes.Structure): _fields_ = [ ('dwSize', wt.DWORD), ('cntUsage', wt.DWORD), ('th32ProcessID', wt.DWORD), ('th32DefaultHeapID', ctypes.POINTER( ctypes.c_ulong )), ('th32ModuleID', wt.DWORD), ('cntThreads', wt.DWORD), ('th32ParentProcessID', wt.DWORD), ('pcPriClassBase', ctypes.c_long), ('dwFlags', wt.DWORD), ('szExeFile', ctypes.c_char * 260) ] TH32CS_SNAPPROCESS = 0x2 k32 = ctypes.windll.kernel32 schnappschuss = k32.CreateToolhelp32Snapshot( TH32CS_SNAPPROCESS, 0 ) if schnappschuss == -1: return tabelle eintrag = PROCESSENTRY32() eintrag.dwSize = ctypes.sizeof( PROCESSENTRY32 ) if k32.Process32First( schnappschuss, ctypes.byref( eintrag ) ): while True: tabelle[ int(eintrag.th32ProcessID) ] = ( int(eintrag.th32ParentProcessID), eintrag.szExeFile.decode( errors='replace' ).lower() ) if not k32.Process32Next( schnappschuss, ctypes.byref( eintrag ) ): break k32.CloseHandle( schnappschuss ) else: for name in os.listdir( '/proc' ): if not name.isdigit(): continue try: with open( '/proc/%s/stat' % name ) as f: stat = f.read() # Format: pid (comm) zustand ppid ... - comm kann # Leerzeichen enthalten, daher an der Klammer trennen kommando = stat[ stat.index('(') + 1 : stat.rindex(')') ] felder = stat[ stat.rindex(')') + 2 : ].split() tabelle[ int(name) ] = ( int(felder[1]), kommando.lower() ) except (OSError, ValueError, IndexError): continue return tabelle def ProzessWurzel(Pid): """laeuft vom Prozess die Elternkette hoch, solange die Eltern auch Python-Prozesse sind, und liefert die Wurzel des Python-Baums. Noetig fuer fremde/alte --reload-Server: wird nur der Arbeits- prozess beendet, startet der Reloader ihn sofort neu.""" tabelle = _ProzessTabelle() aktuell = Pid for _ in range( 10 ): # Schutz gegen Zyklen eintrag = tabelle.get( aktuell ) if eintrag is None: break eltern, _name = eintrag elternEintrag = tabelle.get( eltern ) if elternEintrag is None or not elternEintrag[1].startswith( 'python' ): break aktuell = eltern return aktuell # ------------------------------------------------------------- Aktionen def UvicornKommando(Port, Reload): Kommando = [ sys.executable, '-m', 'uvicorn', 'main:app', '--app-dir', WEBDIR, '--port', str(Port) ] if Reload: Kommando.append( '--reload' ) return Kommando def Starten(Port): if PortOffen( Port ): if IstUnsereApp( Port ): print( 'Server laeuft bereits: http://localhost:%d' % Port ) else: print( 'FEHLER: Port %d ist von einem fremden Programm belegt.' % Port ) return 1 os.makedirs( LOGDIR, exist_ok=True ) logdatei = open( LOGDATEI, 'w' ) # vom Terminal abkoppeln, damit der Server das Schliessen der # Konsole ueberlebt; eigene Prozessgruppe fuer sauberes Beenden if sys.platform == 'win32': flags = ( subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP ) prozess = subprocess.Popen( UvicornKommando( Port, Reload=False ), stdout=logdatei, stderr=subprocess.STDOUT, cwd=BASISDIR, creationflags=flags ) else: prozess = subprocess.Popen( UvicornKommando( Port, Reload=False ), stdout=logdatei, stderr=subprocess.STDOUT, cwd=BASISDIR, start_new_session=True ) PidSchreiben( prozess.pid ) # warten bis der Port antwortet (oder der Prozess frueh stirbt) for _ in range( 30 ): if prozess.poll() is not None: print( 'FEHLER: Server sofort wieder beendet - siehe %s' % LOGDATEI ) PidLoeschen() return 1 if PortOffen( Port ): print( 'Server laeuft: http://localhost:%d (PID %d, Log: %s)' % (Port, prozess.pid, os.path.relpath( LOGDATEI, BASISDIR )) ) print( 'Hinweis: Hintergrundbetrieb ohne Auto-Reload - fuer ' 'Entwicklung mit Reload "run" im Vordergrund nutzen.' ) return 0 time.sleep( 0.5 ) print( 'FEHLER: Server antwortet nicht auf Port %d - siehe %s' % (Port, LOGDATEI) ) return 1 def BaumBeenden(Pid): """beendet einen Prozess samt Kindprozessen (portabel)""" if sys.platform == 'win32': subprocess.run( ['taskkill', '/PID', str(Pid), '/T', '/F'], capture_output=True ) else: try: os.killpg( os.getpgid( Pid ), signal.SIGTERM ) except (ProcessLookupError, PermissionError, OSError): try: os.kill( Pid, signal.SIGTERM ) except OSError: pass # kurze Gnadenfrist, dann hart nachsetzen for _ in range( 10 ): if not PidLebt( Pid ): return time.sleep( 0.3 ) try: os.killpg( os.getpgid( Pid ), signal.SIGKILL ) except (ProcessLookupError, PermissionError, OSError): pass def Stoppen(Port): gefunden = False Pid = PidLesen() if Pid is not None and PidLebt( Pid ): BaumBeenden( Pid ) gefunden = True # Fallback: PID-Datei fehlt/veraltet, aber es lauscht noch jemand - # dann die Wurzel des Python-Prozessbaums beenden (sonst startet ein # --reload-Elternprozess den abgeschossenen Worker sofort neu) if PortOffen( Port ): LPid = ListenerPid( Port ) if LPid is not None and PidLebt( LPid ): BaumBeenden( ProzessWurzel( LPid ) ) gefunden = True # nachpruefen, ob der Port wirklich frei geworden ist for _ in range( 10 ): if not PortOffen( Port ): break time.sleep( 0.3 ) PidLoeschen() if PortOffen( Port ): print( 'WARNUNG: Auf Port %d antwortet immer noch etwas. Moeglicher ' 'verwaister Prozess mit geerbtem Socket - Kandidaten mit ' '"netstat -ano" (Windows) bzw. "ss -tlnp" (Linux) suchen.' % Port ) return 1 print( 'Beendet.' if gefunden else 'Es lief nichts.' ) return 0 def Status(Port): Pid = PidLesen() if PortOffen( Port ): Zusatz = '' if Pid is not None and PidLebt( Pid ): # uvicorn haelt den Port oft in einem Kindprozess des in der # PID-Datei vermerkten Hauptprozesses - das ist derselbe Server Zusatz = ' (PID %d)' % Pid elif ( LPid := ListenerPid( Port ) ) is not None: Zusatz = ' (Listener-PID %d, keine PID-Datei)' % LPid if IstUnsereApp( Port ): print( 'LAEUFT: http://localhost:%d%s' % (Port, Zusatz) ) return 0 print( 'BELEGT: Port %d antwortet, aber nicht mit unserer App%s' % (Port, Zusatz) ) return 1 if Pid is not None and PidLebt( Pid ): print( 'UNKLAR: Prozess %d aus der PID-Datei lebt, aber Port %d ' 'antwortet nicht (Startphase oder haengt?)' % (Pid, Port) ) return 1 print( 'GESTOPPT: kein Server auf Port %d' % Port ) return 1 def Vordergrund(Port): print( 'Starte Web-Testinstanz im Vordergrund mit Auto-Reload ' '(Strg+C zum Beenden) ...' ) try: return subprocess.call( UvicornKommando( Port, Reload=True ), cwd=BASISDIR ) except KeyboardInterrupt: return 0 # ------------------------------------------------------------------ Main if __name__ == '__main__': parser = argparse.ArgumentParser( description='verwaltet die lokale Web-Testinstanz (FastAPI/uvicorn)' ) parser.add_argument( 'aktion', nargs='?', default='run', choices=['start', 'stop', 'status', 'run'], help='start=Hintergrund, stop=beenden, status=anzeigen, ' 'run=Vordergrund mit Auto-Reload (Standard)' ) parser.add_argument( '--port', type=int, default=STANDARDPORT, help='Port der Testinstanz (Standard: %d)' % STANDARDPORT ) argumente = parser.parse_args() if argumente.aktion == 'start': sys.exit( Starten( argumente.port ) ) elif argumente.aktion == 'stop': sys.exit( Stoppen( argumente.port ) ) elif argumente.aktion == 'status': sys.exit( Status( argumente.port ) ) else: sys.exit( Vordergrund( argumente.port ) )