Skip to content

Commit 9f7e057

Browse files
committed
fix(app): force WS reconnect on app-foreground (B10 recovery path)
1 parent 9d7160b commit 9f7e057

5 files changed

Lines changed: 117 additions & 1 deletion

File tree

app/lib/main.dart

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'app/router.dart';
99
import 'app/theme.dart';
1010
import 'app/test_bootstrap.dart';
1111
import 'notifications/notification_observer.dart';
12+
import 'store/connection.dart';
1213
import 'store/store.dart';
1314
import 'ui/widgets/pino_mark.dart';
1415
import 'ui/widgets/srv_request_handler.dart';
@@ -47,9 +48,32 @@ class PinoApp extends ConsumerStatefulWidget {
4748
ConsumerState<PinoApp> createState() => _PinoAppState();
4849
}
4950

50-
class _PinoAppState extends ConsumerState<PinoApp> {
51+
class _PinoAppState extends ConsumerState<PinoApp>
52+
with WidgetsBindingObserver {
5153
bool _showSplash = true;
5254

55+
@override
56+
void initState() {
57+
super.initState();
58+
WidgetsBinding.instance.addObserver(this);
59+
}
60+
61+
@override
62+
void dispose() {
63+
WidgetsBinding.instance.removeObserver(this);
64+
super.dispose();
65+
}
66+
67+
@override
68+
void didChangeAppLifecycleState(AppLifecycleState state) {
69+
// On foreground, nudge a stalled WS connection to reconnect immediately
70+
// instead of waiting out backoff (iOS suspends sockets/timers in the
71+
// background). No-op when already connected.
72+
if (state == AppLifecycleState.resumed) {
73+
ref.read(connectionControllerProvider.notifier).onAppResumed();
74+
}
75+
}
76+
5377
@override
5478
Widget build(BuildContext context) {
5579
if (_showSplash) {

app/lib/store/connection.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,17 @@ class ConnectionController extends StateNotifier<PinoConnState> {
138138
send(Envelope(t: MsgType.srvResponse, id: requestId, body: body));
139139
}
140140

141+
/// Called when the app returns to the foreground. iOS suspends sockets and
142+
/// backoff timers while backgrounded, so a stalled connection can otherwise
143+
/// sit in "reconnecting" for a full backoff interval (up to ~30s) after
144+
/// resume. Nudge the transport to reconnect immediately — but only when we
145+
/// aren't already connected, so a healthy socket isn't needlessly dropped.
146+
void onAppResumed() {
147+
if (state.wsState != WsState.connected) {
148+
_ws?.forceReconnect();
149+
}
150+
}
151+
141152
Future<void> _boot() async {
142153
if (_wsUrl.isNotEmpty) {
143154
// Dev override: connect directly, no pairing.

app/lib/transport/transport.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,10 @@ abstract class Transport {
2929

3030
/// Send an envelope, preserving its caller-supplied id.
3131
void sendEnvelope(Envelope env);
32+
33+
/// Force an immediate reconnect: cancel any pending backoff, reset attempt
34+
/// counters, tear down a stale socket, and open a fresh connection now.
35+
/// No-op if the transport was never connected. Called on app-foreground so
36+
/// a stalled connection recovers instantly instead of waiting out backoff.
37+
void forceReconnect();
3238
}

app/lib/transport/ws_client.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,24 @@ class WsClient implements Transport {
9999
_send(env);
100100
}
101101

102+
/// Force an immediate reconnect (e.g. on app-foreground). Cancels pending
103+
/// backoff, resets the attempt counter, tears down any stale channel, and
104+
/// opens a fresh connection right away. No-op if never connected.
105+
@override
106+
void forceReconnect() {
107+
if (_url == null) return;
108+
_retry?.cancel();
109+
_pinger?.cancel();
110+
_attempt = 0;
111+
unawaited(_sub?.cancel());
112+
_sub = null;
113+
try {
114+
unawaited(_ch?.sink.close());
115+
} catch (_) {}
116+
_ch = null;
117+
unawaited(_open());
118+
}
119+
102120
// ---- internals -----------------------------------------------------------
103121

104122
void _setState(WsState s) {

app/test/connection_controller_test.dart

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ class FakeTransport implements Transport {
5656

5757
@override
5858
void sendEnvelope(Envelope env) {}
59+
60+
int forceReconnectCount = 0;
61+
@override
62+
void forceReconnect() {
63+
forceReconnectCount++;
64+
if (emitConnected) _state.add(WsState.connected);
65+
}
5966
}
6067

6168
/// In-memory [FlutterSecureStorage] so the controller can persist without the
@@ -229,4 +236,54 @@ void main() {
229236
},
230237
);
231238
});
239+
240+
group('ConnectionController app-lifecycle reconnect (B10)', () {
241+
test('onAppResumed forces an immediate transport reconnect when not connected', () async {
242+
final storage = _seededStorage();
243+
final transports = <FakeTransport>[];
244+
final controller = ConnectionController(
245+
storage,
246+
// Never emits connected → controller stays in connecting/reconnecting.
247+
transportFactory: () {
248+
final t = FakeTransport();
249+
transports.add(t);
250+
return t;
251+
},
252+
browseLan: _fixedBrowse(const []),
253+
rediscoverStall: const Duration(seconds: 30),
254+
);
255+
await Future<void>.delayed(Duration.zero);
256+
expect(transports, hasLength(1));
257+
expect(transports[0].forceReconnectCount, 0);
258+
259+
controller.onAppResumed();
260+
261+
expect(transports[0].forceReconnectCount, 1,
262+
reason: 'foreground should nudge a stalled connection to retry now');
263+
controller.dispose();
264+
});
265+
266+
test('onAppResumed does NOT force a reconnect while already connected', () async {
267+
final storage = _seededStorage();
268+
final transports = <FakeTransport>[];
269+
final controller = ConnectionController(
270+
storage,
271+
transportFactory: () {
272+
final t = FakeTransport(emitConnected: true); // healthy connection
273+
transports.add(t);
274+
return t;
275+
},
276+
browseLan: _fixedBrowse(const []),
277+
rediscoverStall: const Duration(seconds: 30),
278+
);
279+
await Future<void>.delayed(Duration.zero);
280+
expect(transports, hasLength(1));
281+
282+
controller.onAppResumed();
283+
284+
expect(transports[0].forceReconnectCount, 0,
285+
reason: 'a healthy socket must not be dropped on every foreground');
286+
controller.dispose();
287+
});
288+
});
232289
}

0 commit comments

Comments
 (0)