Skip to content

Commit 7048cfd

Browse files
feat: implement live data watchdog for improved engine connection stability and recovery handling
1 parent c24bc0c commit 7048cfd

1 file changed

Lines changed: 90 additions & 1 deletion

File tree

dashboard/lib/providers/engine_provider.dart

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import 'dart:async';
22
import 'dart:collection';
3-
import 'dart:io' show Platform;
3+
import 'dart:io' show Platform, Process;
44

55
import 'package:flutter/foundation.dart';
66
import 'package:sentracore_dashboard/models/system_state.dart';
@@ -104,6 +104,22 @@ class EngineProvider extends ChangeNotifier {
104104
Timer? _eventFetchTimer;
105105
Timer? _reconnectTimer;
106106
Timer? _cooldownTicker;
107+
Timer? _liveDataWatchdog;
108+
109+
/// Last successful [/ws/live] payload, or null until the first frame after subscribe.
110+
DateTime? _lastLiveStateAt;
111+
112+
/// When the current live WebSocket subscription was opened.
113+
DateTime? _wsSubscribeAt;
114+
115+
/// Throttle bundled-engine kills from the stale-data watchdog.
116+
DateTime? _lastBundledEngineKillAttempt;
117+
118+
static const Duration _liveDataStaleThreshold = Duration(seconds: 30);
119+
static const Duration _liveDataWatchdogInterval = Duration(seconds: 8);
120+
static const Duration _bundledEngineKillCooldown = Duration(seconds: 50);
121+
122+
bool _recoveringLive = false;
107123

108124
/// When true, [_connectionError] may hold a bootstrap failure; do not clear it in [_tryConnect].
109125
bool _bootstrapErrorPending = false;
@@ -118,6 +134,8 @@ class EngineProvider extends ChangeNotifier {
118134

119135
Future<void> reconnect() async {
120136
_alertFeedFromWs.clear();
137+
_liveDataWatchdog?.cancel();
138+
_liveDataWatchdog = null;
121139
_liveSub?.cancel();
122140
_alertSub?.cancel();
123141
_processFetchTimer?.cancel();
@@ -179,13 +197,17 @@ class EngineProvider extends ChangeNotifier {
179197
_liveSub = stream.listen(
180198
_onStateReceived,
181199
onError: (error) {
200+
_liveDataWatchdog?.cancel();
201+
_liveDataWatchdog = null;
182202
_connected = false;
183203
_bootstrapErrorPending = false;
184204
_connectionError = 'Connection lost. Retrying...';
185205
notifyListeners();
186206
_scheduleReconnect();
187207
},
188208
onDone: () {
209+
_liveDataWatchdog?.cancel();
210+
_liveDataWatchdog = null;
189211
_connected = false;
190212
_bootstrapErrorPending = false;
191213
_connectionError = 'Disconnected. Retrying...';
@@ -208,10 +230,19 @@ class EngineProvider extends ChangeNotifier {
208230
// Stay "disconnected" in UI until first live frame arrives (avoids false
209231
// positive if the socket dies immediately after subscribe).
210232
_connected = false;
233+
_wsSubscribeAt = DateTime.now();
234+
_lastLiveStateAt = null;
235+
_liveDataWatchdog?.cancel();
236+
_liveDataWatchdog = Timer.periodic(
237+
_liveDataWatchdogInterval,
238+
(_) => _checkLiveDataStale(),
239+
);
211240
notifyListeners();
212241
unawaited(_fetchProcesses());
213242
unawaited(_fetchEvents());
214243
} catch (e) {
244+
_liveDataWatchdog?.cancel();
245+
_liveDataWatchdog = null;
215246
_connected = false;
216247
_bootstrapErrorPending = false;
217248
_connectionError = 'Cannot connect to engine. Is it running?';
@@ -220,9 +251,65 @@ class EngineProvider extends ChangeNotifier {
220251
}
221252
}
222253

254+
void _checkLiveDataStale() {
255+
if (_liveSub == null || _recoveringLive) return;
256+
final reference = _lastLiveStateAt ?? _wsSubscribeAt;
257+
if (reference == null) return;
258+
if (DateTime.now().difference(reference) < _liveDataStaleThreshold) {
259+
return;
260+
}
261+
final lastKill = _lastBundledEngineKillAttempt;
262+
if (lastKill != null &&
263+
DateTime.now().difference(lastKill) < _bundledEngineKillCooldown) {
264+
return;
265+
}
266+
unawaited(_recoverStalledLiveChannel());
267+
}
268+
269+
/// HTTP/WS up but no live telemetry (hung orchestrator): restart bundled engine
270+
/// on Windows, then bootstrap again.
271+
Future<void> _recoverStalledLiveChannel() async {
272+
if (_recoveringLive) return;
273+
_recoveringLive = true;
274+
_liveDataWatchdog?.cancel();
275+
_liveDataWatchdog = null;
276+
_reconnectTimer?.cancel();
277+
_lastBundledEngineKillAttempt = DateTime.now();
278+
try {
279+
_connected = false;
280+
_connectionError = 'No live data from engine; restarting engine…';
281+
notifyListeners();
282+
283+
if (Platform.isWindows &&
284+
EngineBundledLauncher.bundledEngineExecutablePath() != null) {
285+
try {
286+
await Process.run(
287+
'taskkill',
288+
const ['/F', '/IM', 'SentraCoreEngine.exe'],
289+
runInShell: true,
290+
);
291+
} catch (_) {
292+
// Process may already be gone.
293+
}
294+
await Future<void>.delayed(const Duration(seconds: 1));
295+
}
296+
297+
_liveSub?.cancel();
298+
_alertSub?.cancel();
299+
_processFetchTimer?.cancel();
300+
_eventFetchTimer?.cancel();
301+
302+
await _bootstrapAndConnect();
303+
} finally {
304+
_recoveringLive = false;
305+
}
306+
}
307+
223308
void _scheduleReconnect() {
224309
_reconnectTimer?.cancel();
225310
_reconnectTimer = Timer(const Duration(seconds: 5), () {
311+
_liveDataWatchdog?.cancel();
312+
_liveDataWatchdog = null;
226313
_liveSub?.cancel();
227314
_alertSub?.cancel();
228315
_processFetchTimer?.cancel();
@@ -292,6 +379,7 @@ class EngineProvider extends ChangeNotifier {
292379
}
293380

294381
void _onStateReceived(SystemState state) {
382+
_lastLiveStateAt = DateTime.now();
295383
if (!_didPullEnginePrefs) {
296384
unawaited(_maybePullEnginePreferences());
297385
}
@@ -358,6 +446,7 @@ class EngineProvider extends ChangeNotifier {
358446

359447
@override
360448
void dispose() {
449+
_liveDataWatchdog?.cancel();
361450
_liveSub?.cancel();
362451
_alertSub?.cancel();
363452
_processFetchTimer?.cancel();

0 commit comments

Comments
 (0)