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 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 17:39:37 +02:00
co-authored by Claude Opus 5.5
parent f09e4875c6
commit e90b954348
11 changed files with 269 additions and 25 deletions
+7
View File
@@ -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
+9 -2
View File
@@ -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
+2
View File
@@ -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
+58
View File
@@ -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<void> 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();
}
+1 -1
View File
@@ -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((_) {});
}
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 (_) {}
}
+1
View File
@@ -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';
+3
View File
@@ -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
}
+4 -20
View File
@@ -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<void> _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<void> _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<Room> _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,
+182
View File
@@ -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<String, String>? _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 = <String, String>{};
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<Client> _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<void> _login(Client client, Map<String, String> 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<bool> _reloginLosesSession(
Map<String, String> acc,
Future<void> 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)),
);
}
}