Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion lib/core/providers/app_startup_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1115,10 +1115,28 @@ class _ForegroundRefreshObserver extends WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// Schedule to avoid side-effects during build frames
Future.microtask(() {
Future.microtask(() async {
try {
refreshConversationsCache(_ref);
_resetConversationWarmup(_ref);
// Re-fetch the OPEN conversation from the server on resume so a reply
// that finished while the app was backgrounded appears immediately —
// independent of the socket (which is often dead/zombie after an Android
// background; OpenWebUI does not replay missed events). The server is the
// source of truth. Without this the user had to pull-to-refresh or restart
// the app to see the reply.
final api = _ref.read(apiServiceProvider);
final active = _ref.read(activeConversationProvider);
if (api != null && active != null) {
final requestedId = active.id;
final full = await api.getConversation(requestedId);
// Stale-write guard: only apply if the user hasn't switched to a
// different conversation during the network round-trip.
final stillActive = _ref.read(activeConversationProvider);
if (stillActive != null && stillActive.id == requestedId) {
_ref.read(activeConversationProvider.notifier).set(full);
}
}
} catch (_) {}
// Resume already kicked off a forced conversations refresh above; only
// finish the warmup work that should run alongside it.
Expand Down
76 changes: 75 additions & 1 deletion lib/core/services/socket_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ class SocketService with WidgetsBindingObserver {
String? _authToken;
bool _isConnecting = false;
bool _isAppForeground = true;
// Tracks a REAL backgrounding (paused/hidden/detached), distinct from the
// transient `inactive` focus loss, so resume logic only fires on a genuine
// background→foreground return.
bool _wasBackgrounded = false;
// Re-entrancy guard so overlapping resume bounces cannot spawn concurrent
// forced reconnects (which would orphan socket.io engines).
bool _resumeReconnectInFlight = false;
// Set while a resume-triggered forced reconnect is pending, so the reconnect
// signal is emitted from _handleConnect (after the session id is available)
// rather than before the handshake completes.
bool _signalReconnectOnConnect = false;
Timer? _heartbeatTimer;
bool _forcePollingFallback = false;

Expand Down Expand Up @@ -231,7 +242,60 @@ class SocketService with WidgetsBindingObserver {

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_isAppForeground = state == AppLifecycleState.resumed;
// Only a REAL background→foreground transition should force-reconnect. The
// `inactive` state is a transient focus loss (notification shade, app
// switcher, permission/system dialog, PiP) — NOT a backgrounding — and must
// not tear down a healthy socket, or it churns the connection on every shade
// peek (and loops on some Samsung permission flows). Treat only
// paused/hidden/detached as background; keep `inactive` foreground for both
// reconnect and delivery semantics.
switch (state) {
case AppLifecycleState.paused:
case AppLifecycleState.hidden:
case AppLifecycleState.detached:
_isAppForeground = false;
_wasBackgrounded = true;
case AppLifecycleState.inactive:
break; // transient focus loss; keep foreground semantics
case AppLifecycleState.resumed:
_isAppForeground = true;
if (_wasBackgrounded) {
_wasBackgrounded = false;
// While backgrounded the OS suspends this isolate and tears down the
// TCP connection; socket.io never sees the disconnect, so the socket
// is a ZOMBIE on resume (connected==true but dead) and the server
// already emitted the streamed completion to it (OpenWebUI does not
// replay). Force a fresh socket + signal reconnect so streaming_helper
// polls the server for the missed completion.
unawaited(_reconnectAfterResume());
}
}
}

/// Force-recreates the socket after returning from background and notifies
/// reconnect listeners so any in-flight stream re-syncs the missed completion.
/// Re-entrancy-guarded so overlapping resume bounces cannot spawn multiple
/// concurrent forced connects.
Future<void> _reconnectAfterResume() async {
if (_resumeReconnectInFlight) return;
_resumeReconnectInFlight = true;
// A forced fresh connect fires 'connect', not 'reconnect', so onReconnect
// listeners (streaming_helper's missed-completion recovery) would not run on
// their own. Have _handleConnect emit the signal once the handshake has
// completed and the new session id is available; emitting here (right after
// connect() returns) would fire before the 'connect' event while sessionId is
// still null, so the recovery would skip the session-id update.
_signalReconnectOnConnect = true;
try {
// force: true disposes the (possibly zombie) socket and opens a fresh one.
await connect(force: true);
} catch (_) {
// Connection setup failed outright; clear the pending signal so it cannot
// later fire on an unrelated connect.
_signalReconnectOnConnect = false;
} finally {
_resumeReconnectInFlight = false;
}
}
Comment thread
X-15 marked this conversation as resolved.

String? get sessionId => _socket?.id;
Expand Down Expand Up @@ -631,6 +695,16 @@ class SocketService with WidgetsBindingObserver {

// Emit health update
_emitHealthUpdate();

// If this connect was triggered by a background→foreground resume, signal
// recovery now that the session id is available, so listeners refresh their
// handler session ids AND poll the server for a missed completion.
if (_signalReconnectOnConnect) {
_signalReconnectOnConnect = false;
if (!_reconnectController.isClosed) {
_reconnectController.add(null);
}
}
}

void _handleReconnectAttempt(dynamic attempt) {
Expand Down