From e90b9543482d58e5d47c8f662a7805e18ef4f1d5 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Wed, 7 Oct 2026 17:39:37 +0200 Subject: [PATCH] fix: Abmelden ohne verspaetete Sync-Antwort (SDK-Wettlauf) + Live-Regressionstest logoutWithoutStraySync (lib/core/session_logout.dart): Sync-Schleife anhalten, laufenden Long-Poll per Account-Daten wecken und auslaufen lassen, erst dann abmelden - es ist keine Anfrage mit altem Token mehr unterwegs, die per 401 ein zweites clear() ausloesen koennte. Genutzt an allen drei Abmelde-Stellen (Einstellungen, Ueber, Konto geloescht) und im E2E-Live-Test. Live gemessen: die verspaetete 401 tritt real auf, kam aber stets vor dem Abschluss einer Neuanmeldung an - Risiko war klein, ist jetzt ausgeschlossen. test/live_logout_race_test.dart in scripts/test.ps1 -Live. Co-Authored-By: Claude Opus 5.5 --- PROGRESS.md | 7 + ROADMAP.md | 11 +- docs/TESTS_UND_BENCHMARKS.md | 2 + lib/core/session_logout.dart | 58 +++++++ lib/widgets/settings/settings_about.dart | 2 +- lib/widgets/settings/settings_account.dart | 2 +- lib/widgets/settings/settings_shared.dart | 2 +- lib/widgets/settings_modal.dart | 1 + scripts/test.ps1 | 3 + test/live_encryption_e2e_test.dart | 24 +-- test/live_logout_race_test.dart | 182 +++++++++++++++++++++ 11 files changed, 269 insertions(+), 25 deletions(-) create mode 100644 lib/core/session_logout.dart create mode 100644 test/live_logout_race_test.dart diff --git a/PROGRESS.md b/PROGRESS.md index 05d60ff..d412eaa 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -42,6 +42,13 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format: v0.1.36 (SHA-256 c3de66be…), Release-APK startet auf dem Emulator ohne Fehler. NICHT auf Gitea veröffentlicht (Bernd testet zuerst). +- **SDK-Logout-Wettlauf** geschlossen: `lib/core/session_logout.dart` + (`logoutWithoutStraySync`) an allen drei Abmelde-Stellen; Live-Test + `test/live_logout_race_test.dart` (Leerlauf + direkt nach Login, je 45 s + beobachtet). Ehrliche Einordnung: verspätete 401 tritt real auf, kam aber + stets vor Abschluss der Neuanmeldung – Risiko war klein. E2E-Live-Test nutzt + jetzt denselben App-Baustein. + **Offen/Nächster Schritt:** Bernd testet 0.1.37 auf dem Handy (Push mit mehreren Nachrichten + Bild, „Gelesen“, Statusleiste, Vollansicht-Gesten, Sprechblasen, Schriftgröße). Danach ROADMAP: SDK-Logout-Wettlauf im diff --git a/ROADMAP.md b/ROADMAP.md index 388e365..5370f4c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -169,8 +169,15 @@ Punkte abhaken (`[x]`), wenn erledigt UND in `PROGRESS.md` protokolliert. E2EE-Empfang live belegt, auth_log unauffällig; App läuft seit 12:43 für die Langzeit-Beobachtung weiter. **Offen (Klick/Bernd):** Senden, Logout-Warnung, Re-Login mit Lesbarkeits-Check. -- [ ] **SDK-Wettlauf: verspätete 401 einer alten Sync-Anfrage löscht ggf. die - NEUE Session** (gefunden 2026-10-07 über einen sporadisch roten +- [x] **SDK-Wettlauf: verspätete 401 einer alten Sync-Anfrage löscht ggf. die + NEUE Session** – ERLEDIGT 2026-10-07: alle App-Abmeldungen laufen über + `logoutWithoutStraySync` (lib/core/session_logout.dart: Sync anhalten, + Long-Poll per Account-Daten wecken, auslaufen lassen, dann abmelden). + Live gemessen (test/live_logout_race_test.dart): die verspätete 401 tritt + real auf, kam aber in allen Läufen an, BEVOR die Neuanmeldung fertig + war – reales Risiko in der App klein (Neuanmeldung binnen ~1 s nötig), + jetzt ausgeschlossen; Regressionstest in `scripts/test.ps1 -Live`. + Ursprüngliche Analyse: (gefunden 2026-10-07 über einen sporadisch roten Live-Test, siehe PROGRESS.md). matrix 6.2 `Client._innerSync` wirft den Fehler einer Sync-Antwort, BEVOR geprüft wird, ob die Anfrage noch aktuell ist (`_currentSyncId`). Läuft beim Logout noch ein Long-Poll diff --git a/docs/TESTS_UND_BENCHMARKS.md b/docs/TESTS_UND_BENCHMARKS.md index 463de73..842bc91 100644 --- a/docs/TESTS_UND_BENCHMARKS.md +++ b/docs/TESTS_UND_BENCHMARKS.md @@ -31,6 +31,8 @@ oder zwei echten Geräten prüfen kann, steht weiterhin in `docs/PC_TESTPLAN.md` | `streaming_presets_test.dart` | 60-FPS-Streaming-Presets (Auflösung/FPS/Bitrate) | immer | | `local_message_search_test.dart` | Lokale Nachrichtensuche (Strg+K): Treffer, Sortierung, Groß-/Kleinschreibung inkl. Umlaute, Medien-Filter, Trefferdeckel | immer (neu 2026-10-07) | | `live_encryption_e2e_test.dart` | Echter Login (pyramidtest1) → Platte verschlüsselt → Neustart → Logout → Re-Login → alte Nachricht lesbar; Utas Update-Fall mit echter Session | nur mit `-Live` | +| `live_logout_race_test.dart` | Abmelden + sofortige Neuanmeldung auf demselben Client: neue Session muss bleiben (SDK-Wettlauf, `logoutWithoutStraySync`); mit `PYRAMID_LIVE_RACE_PROOF=1` zusätzlich Nachweis mit altem Weg | nur mit `-Live` | +| `live_ui_fixture_test.dart` | Kein Test im engeren Sinn: legt Testinhalte (DM + Gruppe mit Bildern) für den UI-Durchlauf an | nur manuell mit `PYRAMID_LIVE_TEST=1` | | `live_call_setup_test.dart` | Kein Test im engeren Sinn: legt den Voice-Testraum für GUI-Call-Tests mit zwei Test-Accounts an | nur manuell mit `PYRAMID_LIVE_TEST=1` | `test/support/synthetic_matrix_data.dart` erzeugt synthetische Räume und diff --git a/lib/core/session_logout.dart b/lib/core/session_logout.dart new file mode 100644 index 0000000..354c92b --- /dev/null +++ b/lib/core/session_logout.dart @@ -0,0 +1,58 @@ +import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix.dart'; + +/// Kennung der Account-Daten, mit denen wir den laufenden Sync aufwecken. +const logoutWakeAccountDataType = 'io.pyramid.logout_wake'; + +/// Meldet [client] ab, ohne dass eine verspätete Server-Antwort die NÄCHSTE +/// Anmeldung wieder löscht. +/// +/// Hintergrund (SDK-Wettlauf, ROADMAP M1, gefunden 2026-10-07): matrix 6.2 +/// (und Upstream `main`) prüft bei einer FEHLER-Antwort des Sync-Long-Polls +/// nicht, ob die Anfrage noch aktuell ist. Läuft beim Abmelden noch ein +/// Long-Poll (bis 30 s) mit dem alten Token, kommt dessen 401 +/// (M_UNKNOWN_TOKEN) später zurück und das SDK ruft `clear()` – meldet sich +/// der Nutzer in der Zwischenzeit auf demselben Client neu an (Pyramid +/// benutzt nach dem Logout dasselbe Client-Objekt weiter), wird die frische +/// Session gelöscht: ein „Random-Logout“ direkt nach dem Login. +/// +/// Ablauf hier: +/// 1. Sync-Schleife anhalten (nach der laufenden Anfrage keine neue). +/// 2. Laufenden Long-Poll WECKEN: geänderte Account-Daten lässt der Server +/// sofort antworten – statt bis zu 30 s zu warten. +/// 3. Die laufende Anfrage regulär (mit gültigem Token) zu Ende gehen lassen. +/// 4. Erst dann serverseitig abmelden – es ist keine Anfrage mehr unterwegs. +/// +/// Schritt 2/3 sind „best effort“: scheitern sie (offline), wird nach +/// höchstens ~8 s trotzdem abgemeldet – nie schlechter als das bisherige +/// direkte `logout()`. +/// +/// Live gemessen (test/live_logout_race_test.dart, 2026-10-07): die +/// verspätete 401 tritt real auf, kommt aber meist an, BEVOR ein Mensch sich +/// neu angemeldet hat – das Risiko ist klein, dieser Weg schließt es aus. +Future logoutWithoutStraySync( + Client client, { + Duration settleTimeout = const Duration(seconds: 5), +}) async { + client.backgroundSync = false; + // Liefert den laufenden Sync (oder startet einen kurzen ohne Long-Poll). + final inFlight = client.oneShotSync(timeout: Duration.zero); + final userId = client.userID; + if (userId != null) { + try { + await client + .setAccountData(userId, logoutWakeAccountDataType, { + 'ts': DateTime.now().millisecondsSinceEpoch, + }) + .timeout(const Duration(seconds: 3)); + } catch (e) { + debugPrint('[LOGOUT] Sync-Wecken fehlgeschlagen (egal): $e'); + } + } + try { + await inFlight.timeout(settleTimeout); + } catch (e) { + debugPrint('[LOGOUT] laufender Sync nicht rechtzeitig fertig: $e'); + } + await client.logout(); +} diff --git a/lib/widgets/settings/settings_about.dart b/lib/widgets/settings/settings_about.dart index 00e02cd..b430865 100644 --- a/lib/widgets/settings/settings_about.dart +++ b/lib/widgets/settings/settings_about.dart @@ -130,7 +130,7 @@ class _AboutSection extends ConsumerWidget { ); if (confirm == true) { unawaited(AuthLog.write('User-initiated logout (Settings)')); - await client.logout().catchError((_) {}); + await logoutWithoutStraySync(client).catchError((_) {}); } } } diff --git a/lib/widgets/settings/settings_account.dart b/lib/widgets/settings/settings_account.dart index ce55f65..863f39d 100644 --- a/lib/widgets/settings/settings_account.dart +++ b/lib/widgets/settings/settings_account.dart @@ -192,7 +192,7 @@ class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> { } // Konto wurde gelöscht — ausloggen unawaited(AuthLog.write('User-initiated logout (Konto gelöscht)')); - try { await client.logout(); } catch (_) {} + try { await logoutWithoutStraySync(client); } catch (_) {} } @override diff --git a/lib/widgets/settings/settings_shared.dart b/lib/widgets/settings/settings_shared.dart index d5aed86..6902da2 100644 --- a/lib/widgets/settings/settings_shared.dart +++ b/lib/widgets/settings/settings_shared.dart @@ -218,7 +218,7 @@ class _LogoutNavButtonState extends ConsumerState<_LogoutNavButton> { try { widget.onClose(); unawaited(AuthLog.write('User-initiated logout (Settings-Sidebar)')); - await client.logout(); + await logoutWithoutStraySync(client); } catch (_) {} } diff --git a/lib/widgets/settings_modal.dart b/lib/widgets/settings_modal.dart index 32b7181..45393e6 100644 --- a/lib/widgets/settings_modal.dart +++ b/lib/widgets/settings_modal.dart @@ -28,6 +28,7 @@ import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMis import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart'; import 'package:pyramid/core/matrix_client.dart'; +import 'package:pyramid/core/session_logout.dart'; import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/update_checker.dart'; diff --git a/scripts/test.ps1 b/scripts/test.ps1 index 606f09f..4b7e1cc 100644 --- a/scripts/test.ps1 +++ b/scripts/test.ps1 @@ -85,6 +85,9 @@ if ($Live) { Invoke-Step "Live-Test: SQLCipher mit echtem Login (pyramidtest1)" { flutter test test/live_encryption_e2e_test.dart --concurrency=1 } + Invoke-Step "Live-Test: Logout + sofortiger Re-Login (SDK-Wettlauf)" { + flutter test test/live_logout_race_test.dart --concurrency=1 + } } finally { Remove-Item Env:PYRAMID_LIVE_TEST -ErrorAction SilentlyContinue } diff --git a/test/live_encryption_e2e_test.dart b/test/live_encryption_e2e_test.dart index c241c05..2d82313 100644 --- a/test/live_encryption_e2e_test.dart +++ b/test/live_encryption_e2e_test.dart @@ -12,6 +12,7 @@ import 'package:matrix/matrix.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:sqlite3/open.dart' as sqlite_open; import 'package:pyramid/core/app_database.dart'; +import 'package:pyramid/core/session_logout.dart'; /// LIVE-End-to-End-Test der SQLCipher-Verschlüsselung mit einem ECHTEN /// Matrix-Login gegen steggi-matrix.work (Test-Account, NICHT Bernd/Uta). @@ -207,23 +208,6 @@ Future _login(Client client, _TestAccounts acc, ); } -/// Logout ohne Nachzügler. matrix 6.2 prüft bei einer FEHLER-Antwort des -/// Sync-Long-Polls nicht, ob die Anfrage noch aktuell ist: Läuft beim Logout -/// noch ein Long-Poll mit dem alten Token, kommt dessen 401 (M_UNKNOWN_TOKEN) -/// später zurück und das SDK ruft erneut `clear()`. Direkt nach einem -/// Re-Login würde das die NEUE Session löschen, nach `dispose()` wirft es -/// (DB geschlossen → Fallback auf die globale sqflite-Factory) – genau das -/// machte diesen Test sporadisch rot (2026-10-07). Deshalb erst die -/// Sync-Schleife anhalten, den laufenden Long-Poll regulär auslaufen lassen -/// (≤ 30 s), dann abmelden. `clear()` schaltet backgroundSync wieder ein. -Future _logoutWithoutStraySync(Client client) async { - client.backgroundSync = false; - await client - .oneShotSync(timeout: Duration.zero) - .timeout(const Duration(seconds: 60)); - await client.logout(); -} - /// Holt (oder erzeugt) den unverschlüsselten Autopilot-Testraum. Future _ensureTestRoom(Client client) async { const roomName = 'Pyramid Autopilot Testraum'; @@ -357,7 +341,7 @@ void main() { expect(client.userID?.toLowerCase(), acc.user1.toLowerCase()); // ── Phase 4: Logout → Re-Login → alte Nachricht wieder lesbar ── - await _logoutWithoutStraySync(client); + await logoutWithoutStraySync(client); // App-Baustein (session_logout.dart) expect(client.isLogged(), isFalse); await _login(client, acc, user: acc.user1, pass: acc.pass1, device: 'Autopilot Enc-Test 2'); @@ -365,7 +349,7 @@ void main() { expect(await _timelineContains(roomAfter, marker), isTrue, reason: 'Alte Nachricht nach Logout+Re-Login nicht mehr lesbar'); // Aufräumen: Session serverseitig beenden, DB wird dabei geleert. - await _logoutWithoutStraySync(client); + await logoutWithoutStraySync(client); // App-Baustein (session_logout.dart) await client.dispose(closeDatabase: true); }, skip: skip, @@ -431,7 +415,7 @@ void main() { expect(roomAfter, isNotNull, reason: 'Testraum nach Migration nicht mehr in der DB'); // Aufräumen (Session serverseitig beenden). - await _logoutWithoutStraySync(client); + await logoutWithoutStraySync(client); // App-Baustein (session_logout.dart) await client.dispose(closeDatabase: true); }, skip: skip, diff --git a/test/live_logout_race_test.dart b/test/live_logout_race_test.dart new file mode 100644 index 0000000..b9d4ecc --- /dev/null +++ b/test/live_logout_race_test.dart @@ -0,0 +1,182 @@ +@TestOn('windows') +library; + +import 'dart:ffi'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/session_logout.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:sqlite3/open.dart' as sqlite_open; + +/// LIVE-Test des SDK-Logout-Wettlaufs (ROADMAP M1, gefunden 2026-10-07) mit +/// dem Test-Account pyramidtest1 – gegated wie die anderen Live-Tests +/// (`PYRAMID_LIVE_TEST=1`), läuft nie im normalen `flutter test`. +/// +/// Szenario wie in der App: Abmelden und SOFORT auf demselben Client-Objekt +/// wieder anmelden, dann 45 s beobachten. Kommt die 401 des alten Long-Polls +/// verspätet zurück, löscht das SDK die NEUE Session (`clear()`). +/// +/// * „Nachweis“ (nur mit zusätzlich `PYRAMID_LIVE_RACE_PROOF=1`): der alte +/// Weg (direktes `client.logout()`) – zeigt, ob der Wettlauf auftritt. +/// * Regressionstest: `logoutWithoutStraySync` – die neue Session MUSS +/// eingeloggt bleiben. + +Map? _loadAccounts() { + final override = Platform.environment['PYRAMID_TEST_ACCOUNTS']; + final profile = Platform.environment['USERPROFILE'] ?? ''; + final file = + File(override ?? '$profile\\.pyramid-autopilot\\test-accounts.txt'); + if (!file.existsSync()) return null; + final map = {}; + for (final line in file.readAsLinesSync()) { + final t = line.trim(); + if (t.isEmpty || t.startsWith('#')) continue; + final i = t.indexOf('='); + if (i > 0) map[t.substring(0, i)] = t.substring(i + 1); + } + return map; +} + +String? _findSqliteDll() { + for (final config in ['Debug', 'Profile', 'Release']) { + final file = File('build/windows/x64/runner/$config/sqlite3.dll'); + if (file.existsSync()) return file.absolute.path; + } + return null; +} + +Future _newClient(String name) async { + final dll = _findSqliteDll()!; + final factory = createDatabaseFactoryFfi( + ffiInit: () { + sqlite_open.open.overrideFor( + sqlite_open.OperatingSystem.windows, + () => DynamicLibrary.open(dll), + ); + }, + noIsolate: true, + ); + final db = await factory.openDatabase(inMemoryDatabasePath); + final client = + Client(name, database: await MatrixSdkDatabase.init(name, database: db)); + await client.init( + waitForFirstSync: false, waitUntilLoadCompletedLoaded: false); + return client; +} + +Future _login(Client client, Map acc, String device) async { + await client.checkHomeserver(Uri.parse(acc['homeserver']!)); + await client.login( + LoginType.mLoginPassword, + identifier: AuthenticationUserIdentifier(user: acc['user1']!), + password: acc['pass1']!, + initialDeviceDisplayName: device, + refreshToken: true, + ); +} + +/// Abmelden → sofort neu anmelden → [watch] lang beobachten. Liefert, ob die +/// NEUE Session dabei verloren ging. +Future _reloginLosesSession( + Map acc, + Future Function(Client) logout, + String label, { + Duration watch = const Duration(seconds: 45), + bool logoutRightAfterLogin = false, +}) async { + final client = await _newClient('PyramidRace$label'); + try { + await _login(client, acc, 'Autopilot Race $label 1'); + if (!logoutRightAfterLogin) { + // Warten, bis die Sync-Schleife im Long-Poll hängt (erster Sync hat + // timeout 0, danach 30-s-Long-Polls). + await client.oneShotSync().timeout(const Duration(seconds: 60)); + await Future.delayed(const Duration(seconds: 3)); + } + // Sonst: sofort abmelden, während die Schleife nach dem ersten (schnellen) + // Sync gerade neue Anfragen mit dem alten Token losschickt. + + await logout(client); + expect(client.isLogged(), isFalse); + + await _login(client, acc, 'Autopilot Race $label 2'); + final newToken = client.accessToken; + var lost = false; + final sub = client.onLoginStateChanged.stream.listen((s) { + if (s == LoginState.loggedOut) lost = true; + }); + final end = DateTime.now().add(watch); + while (DateTime.now().isBefore(end) && !lost) { + await Future.delayed(const Duration(seconds: 1)); + } + await sub.cancel(); + lost = lost || !client.isLogged() || client.accessToken != newToken; + // ignore: avoid_print + print('[$label] neue Session nach ${watch.inSeconds}s ' + '${lost ? 'VERLOREN' : 'noch eingeloggt'}'); + return lost; + } finally { + if (client.isLogged()) { + try { + await logoutWithoutStraySync(client); + } catch (_) {} + } + await client.dispose(closeDatabase: true); + } +} + +void main() { + final acc = _loadAccounts(); + final skip = Platform.environment['PYRAMID_LIVE_TEST'] != '1' + ? 'Live-Test nur mit PYRAMID_LIVE_TEST=1 (echter Homeserver!)' + : acc == null + ? 'Test-Account-Datei fehlt' + : _findSqliteDll() == null + ? 'SQLite-DLL fehlt (erst flutter build windows)' + : null; + + for (final immediately in [false, true]) { + final when = immediately ? 'direkt nach dem Login' : 'nach Leerlauf'; + final tag = immediately ? 'sofort' : 'leerlauf'; + + test( + 'Nachweis ($when): direktes logout() + sofortiger Re-Login (alter Weg)', + () async { + final lost = await _reloginLosesSession( + acc!, + (c) => c.logout(), + 'alt-$tag', + logoutRightAfterLogin: immediately, + ); + // Kein expect: dokumentiert nur, ob der Wettlauf auftritt. + // ignore: avoid_print + print('Wettlauf ($when) mit altem Weg: ${lost ? 'JA' : 'nein'}'); + }, + skip: skip ?? + (Platform.environment['PYRAMID_LIVE_RACE_PROOF'] != '1' + ? 'Nachweis nur mit PYRAMID_LIVE_RACE_PROOF=1' + : null), + timeout: const Timeout(Duration(minutes: 4)), + ); + + test( + 'logoutWithoutStraySync ($when): neue Session bleibt nach sofortigem ' + 'Re-Login', + () async { + final lost = await _reloginLosesSession( + acc!, + logoutWithoutStraySync, + 'neu-$tag', + logoutRightAfterLogin: immediately, + ); + expect(lost, isFalse, + reason: 'verspätete 401 einer alten Sync-Anfrage hat die neue ' + 'Session gelöscht'); + }, + skip: skip, + timeout: const Timeout(Duration(minutes: 4)), + ); + } +}