From de866b12b42f81cc7d17450ee7e153cd7a53008f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 24 Sep 2026 22:33:00 +0200 Subject: [PATCH] fixed stalled requests after app resume --- .../autocomplete/autocomplete_api.dart | 8 +++++++- lib/api/marianumcloud/talk/talk_api.dart | 6 +++++- lib/api/marianumconnect/marianumconnect_api.dart | 10 ++++++++++ lib/app.dart | 2 ++ lib/state/app/modules/chat/bloc/chat_bloc.dart | 15 ++++++++++----- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart index 1423c43..0394871 100644 --- a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import '../../../model/endpoint_data.dart'; import '../../errors/server_exception.dart'; +import '../../http_errors.dart'; import '../nextcloud_ocs.dart'; import 'autocomplete_response.dart'; @@ -30,7 +31,12 @@ class AutocompleteApi { 'limit': '10', }, ); - final response = await http.get(uri, headers: NextcloudOcs.headers()); + final response = (await sendGuarded( + 'Autocomplete $uri', + () => http + .get(uri, headers: NextcloudOcs.headers()) + .timeout(const Duration(seconds: 20)), + ))!; if (response.statusCode != HttpStatus.ok) { throw ServerException( statusCode: response.statusCode, diff --git a/lib/api/marianumcloud/talk/talk_api.dart b/lib/api/marianumcloud/talk/talk_api.dart index f8c3e60..78282bd 100644 --- a/lib/api/marianumcloud/talk/talk_api.dart +++ b/lib/api/marianumcloud/talk/talk_api.dart @@ -12,6 +12,10 @@ import '../nextcloud_ocs.dart'; import 'talk_error.dart'; abstract class TalkApi { + // package:http has no timeout of its own; a request stuck on a dead + // connection (e.g. one started right before a suspend) would spin forever. + static const Duration _timeout = Duration(seconds: 30); + String path; ApiParams? body; Map? headers; @@ -35,7 +39,7 @@ abstract class TalkApi { final data = await sendGuarded( 'Talk $endpoint', - () => request(endpoint, body, mergedHeaders), + () => request(endpoint, body, mergedHeaders)?.timeout(_timeout), ); if (data == null) { throw const NetworkException( diff --git a/lib/api/marianumconnect/marianumconnect_api.dart b/lib/api/marianumconnect/marianumconnect_api.dart index 5030ea0..ad38c3c 100644 --- a/lib/api/marianumconnect/marianumconnect_api.dart +++ b/lib/api/marianumconnect/marianumconnect_api.dart @@ -16,6 +16,16 @@ class MarianumConnectApi { static Dio dio() => _instance; + /// Drops the pooled keep-alive sockets. After a suspend the OS or a NAT + /// has usually killed them already, but dart:io only notices once a request + /// is written into one — which then hangs until [_receiveTimeout]. + /// In-flight requests finish on the old adapter. + static void resetConnections() { + final stale = _instance.httpClientAdapter; + _instance.httpClientAdapter = HttpClientAdapter(); + stale.close(); + } + /// A fresh dio with the standard JSON options but no interceptors — used by /// the auth queries (login/verify) that must bypass the bearer/demo /// interceptors to avoid a re-auth loop. diff --git a/lib/app.dart b/lib/app.dart index c346f69..c8ae5d1 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import 'api/marianumconnect/marianumconnect_api.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'main.dart'; @@ -98,6 +99,7 @@ class _AppState extends State with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { log('AppLifecycle: $state'); if (state == AppLifecycleState.resumed) { + MarianumConnectApi.resetConnections(); _reportTelemetry(); Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () { if (!mounted) return; diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index 75dfd52..a36acfc 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -23,6 +23,9 @@ class ChatBloc final ChatListBloc? _chatListBloc; String? _pollingToken; + // A loop parked in its request survives stop+start for the same token + // (pause/resume); the generation retires it so only one loop polls. + int _pollGeneration = 0; int _backoffMs = 0; int _lastKnownMessageId = 0; bool _appResumed = true; @@ -265,23 +268,25 @@ class ChatBloc _pollingToken = token; _backoffMs = 0; _lastKnownMessageId = _maxMessageId(innerState?.chatResponse); - unawaited(_pollLoop(token)); + unawaited(_pollLoop(token, ++_pollGeneration)); } void _stopLongPoll() { _pollingToken = null; + _pollGeneration++; _backoffMs = 0; } - Future _pollLoop(String token) async { - while (_pollingToken == token && !isClosed) { + Future _pollLoop(String token, int generation) async { + bool active() => generation == _pollGeneration && !isClosed; + while (active()) { try { final response = await LongPollChat( chatToken: token, lastKnownMessageId: _lastKnownMessageId, ).run(); - if (_pollingToken != token || isClosed) return; + if (!active()) return; _backoffMs = 0; if (response == null) continue; @@ -305,7 +310,7 @@ class ChatBloc _chatListBloc?.markRoomAsRead(token, _lastKnownMessageId); } } on Object catch (e) { - if (_pollingToken != token || isClosed) return; + if (!active()) return; log('LongPoll error for $token: $e'); _backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000); await Future.delayed(Duration(milliseconds: _backoffMs));