""" Startup-Reaper fuer verwaiste Sitzungen (Umsetzungsauftrag_Sonnet5.md, E7). app/security/active_sessions.py ist bewusst rein prozesslokal -- bei einem Absturz oder Neustart des uvicorn-Prozesses geht diese Registry verloren, aber die zugehoerigen Zeilen in der Tabelle 'sessions' bleiben mit ended_at IS NULL stehen, weil kein Task mehr existiert, der den regulaeren finally-Cleanup-Pfad (Update + Audit-Event) durchlaeuft. Ohne Korrektur wachsen diese 'Geistersitzungen' mit jedem Neustart an: die Superadmin-Sessionview zeigt sie als aktiv, aber nicht beendbar (killable: false), und jede kuenftige Zaehlung 'wie viele Sitzungen hat dieser Benutzer offen' (E6, Teil F) rechnet mit ihnen. reap_orphaned_sessions() wird einmalig beim Start aufgerufen (app/main.py:: lifespan, NACH init_db() -- die Migrationen muessen gelaufen sein) und schliesst alle offenen Sitzungen mit end_reason='server_restart'. Es ist zu diesem Zeitpunkt garantiert korrekt, JEDE offene Sitzung so zu behandeln: der Prozess startet gerade erst, es kann also noch keine echte, laufende Sitzung dieses neuen Prozesses geben. """ from __future__ import annotations import logging import aiosqlite from app.security.audit import write_audit_event logger = logging.getLogger("jumphost.session_reaper") async def reap_orphaned_sessions(conn: aiosqlite.Connection) -> int: """Schliesst alle beim Start noch offenen Sitzungen. Gibt die Anzahl zurueck.""" cursor = await conn.execute( "SELECT id FROM sessions WHERE ended_at IS NULL ORDER BY id" ) rows = await cursor.fetchall() orphaned_ids = [row[0] for row in rows] if not orphaned_ids: logger.info("Keine verwaisten Sitzungen beim Start gefunden.") return 0 await conn.execute( "UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), " "end_reason = 'server_restart' WHERE ended_at IS NULL" ) # Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 3): explizit committen, # BEVOR write_audit_event() unten aufgerufen wird. write_audit_event() # oeffnet immer ein eigenes "BEGIN IMMEDIATE" (app/security/audit.py, E1) # -- ohne diesen Commit bleibt das UPDATE oben (je nach isolation_level # der uebergebenen Connection) als offene Transaktion stehen, und das # BEGIN IMMEDIATE schlaegt mit "cannot start a transaction within a # transaction" fehl. In der Produktion faellt das nicht auf, weil # app/db.py::init_db() die einzige echte Connection bewusst mit # isolation_level=None (Autocommit) oeffnet -- dort committet das UPDATE # bereits von selbst. reap_orphaned_sessions() bekommt seine Connection # aber als Parameter uebergeben und darf sich auf dieses Detail des # Aufrufers nicht verlassen (siehe tests/test_session_reaper.py, das # bewusst eine eigene :memory:-Connection MIT Standard-isolation_level # verwendet und den Fehler dadurch reproduzierbar machte). await conn.commit() logger.warning( "Startup-Reaper: %d verwaiste Sitzung(en) aus vorherigem Prozess geschlossen " "(end_reason=server_restart): %s", len(orphaned_ids), orphaned_ids, ) # Ein einziges Sammel-Audit-Ereignis statt eines je Sitzung (E7) -- die # Anzahl und betroffenen IDs sind das, was fuer die Nachvollziehbarkeit # gebraucht wird; ein Ereignis pro Zeile waere Rauschen im Log. await write_audit_event( conn, event_type="session_reaper_server_restart", user_id=None, client_ip=None, details={"count": len(orphaned_ids), "session_ids": orphaned_ids}, ) return len(orphaned_ids)