Skip to content

Commit cc4b18e

Browse files
feat: add engine health watchdog to monitor engine status and implement automatic reconnection logic
1 parent eb70783 commit cc4b18e

6 files changed

Lines changed: 83 additions & 85 deletions

File tree

dashboard/lib/main.dart

Lines changed: 27 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class _StartupGateAppState extends State<StartupGateApp> {
3131
_boot = _bootstrap();
3232
}
3333

34-
Future<_BootResult> _bootstrap({bool userRetry = false}) async {
34+
Future<_BootResult> _bootstrap() async {
3535
final notifications = DesktopNotificationService();
3636
await notifications.init(
3737
onDidReceiveNotificationResponse: (response) {
@@ -43,13 +43,9 @@ class _StartupGateAppState extends State<StartupGateApp> {
4343
);
4444
final settings = SettingsProvider();
4545
await settings.load();
46-
47-
// READY GATE (strict): do not enter the app until engine is healthy.
48-
final out = userRetry
49-
? await EngineBundledLauncher.ensureReadyUserRetry()
50-
: await EngineBundledLauncher.ensureReady();
51-
return _BootResult(
52-
settings: settings, notifications: notifications, gate: out);
46+
// Engine startup is handled by EngineProvider (single owner) to avoid
47+
// duplicate launches on app boot.
48+
return _BootResult(settings: settings, notifications: notifications);
5349
}
5450

5551
@override
@@ -69,21 +65,10 @@ class _StartupGateAppState extends State<StartupGateApp> {
6965
}
7066

7167
final data = snap.data!;
72-
if (!data.gate.success) {
73-
return MaterialApp(
74-
debugShowCheckedModeBanner: false,
75-
theme: theme,
76-
darkTheme: dark,
77-
home: _StartupError(
78-
message: data.gate.message ?? 'Engine failed to start.',
79-
onRetry: () =>
80-
setState(() => _boot = _bootstrap(userRetry: true)),
81-
),
82-
);
83-
}
84-
8568
return SentraCoreApp(
86-
settings: data.settings, notifications: data.notifications);
69+
settings: data.settings,
70+
notifications: data.notifications,
71+
);
8772
},
8873
);
8974
}
@@ -92,11 +77,9 @@ class _StartupGateAppState extends State<StartupGateApp> {
9277
class _BootResult {
9378
final SettingsProvider settings;
9479
final DesktopNotificationService notifications;
95-
final EngineBootstrapOutcome gate;
9680
const _BootResult({
9781
required this.settings,
9882
required this.notifications,
99-
required this.gate,
10083
});
10184
}
10285

@@ -140,51 +123,10 @@ class _StartupSplash extends StatelessWidget {
140123
}
141124
}
142125

143-
class _StartupError extends StatelessWidget {
144-
final String message;
145-
final VoidCallback onRetry;
146-
const _StartupError({required this.message, required this.onRetry});
147-
148-
@override
149-
Widget build(BuildContext context) {
150-
return Scaffold(
151-
body: Center(
152-
child: Padding(
153-
padding: const EdgeInsets.all(24),
154-
child: ConstrainedBox(
155-
constraints: const BoxConstraints(maxWidth: 520),
156-
child: Column(
157-
mainAxisSize: MainAxisSize.min,
158-
crossAxisAlignment: CrossAxisAlignment.stretch,
159-
children: [
160-
Text(
161-
'Engine failed to start',
162-
style: TextStyle(
163-
color: AppTheme.textPrimaryFor(context),
164-
fontSize: 18,
165-
fontWeight: FontWeight.w800,
166-
),
167-
),
168-
const SizedBox(height: 8),
169-
Text(
170-
message,
171-
style: TextStyle(color: AppTheme.textMutedFor(context)),
172-
),
173-
const SizedBox(height: 16),
174-
FilledButton(
175-
onPressed: onRetry,
176-
child: const Text('Retry'),
177-
),
178-
],
179-
),
180-
),
181-
),
182-
),
183-
);
184-
}
185-
}
126+
// Startup errors are handled inside the app (EngineProvider), so we no longer
127+
// gate app entry on engine health here.
186128

187-
class SentraCoreApp extends StatelessWidget {
129+
class SentraCoreApp extends StatefulWidget {
188130
const SentraCoreApp({
189131
super.key,
190132
required this.settings,
@@ -194,19 +136,32 @@ class SentraCoreApp extends StatelessWidget {
194136
final SettingsProvider settings;
195137
final DesktopNotificationService notifications;
196138

139+
@override
140+
State<SentraCoreApp> createState() => _SentraCoreAppState();
141+
}
142+
143+
class _SentraCoreAppState extends State<SentraCoreApp> {
144+
@override
145+
void dispose() {
146+
// Allow the app to stop the engine on close (owned process only).
147+
// Users can still kill the process externally; we auto-restart on next launch.
148+
EngineBundledLauncher.stopOwnedEngine();
149+
super.dispose();
150+
}
151+
197152
@override
198153
Widget build(BuildContext context) {
199154
return MultiProvider(
200155
providers: [
201-
Provider<DesktopNotificationService>.value(value: notifications),
202-
ChangeNotifierProvider<SettingsProvider>.value(value: settings),
156+
Provider<DesktopNotificationService>.value(value: widget.notifications),
157+
ChangeNotifierProvider<SettingsProvider>.value(value: widget.settings),
203158
ChangeNotifierProvider<HistoryProvider>(
204159
create: (_) => HistoryProvider()..load(),
205160
),
206161
ChangeNotifierProvider(
207162
create: (ctx) => EngineProvider(
208-
settings: settings,
209-
notifications: notifications,
163+
settings: widget.settings,
164+
notifications: widget.notifications,
210165
history: Provider.of<HistoryProvider>(ctx, listen: false),
211166
)..connect(),
212167
),

dashboard/lib/providers/engine_provider.dart

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ class EngineProvider extends ChangeNotifier {
112112
Timer? _reconnectTimer;
113113
Timer? _cooldownTicker;
114114
Timer? _liveDataWatchdog;
115+
Timer? _engineHealthWatchdog;
115116

116117
/// Last successful [/ws/live] payload, or null until the first frame after subscribe.
117118
DateTime? _lastLiveStateAt;
@@ -153,6 +154,7 @@ class EngineProvider extends ChangeNotifier {
153154
_eventFetchTimer?.cancel();
154155
_reconnectTimer?.cancel();
155156
_cooldownTicker?.cancel();
157+
_engineHealthWatchdog?.cancel();
156158
_service.dispose();
157159
final cfg = await EngineConfigStore.readOrCreate();
158160
_service = EngineService(host: cfg.host, port: cfg.port);
@@ -238,6 +240,14 @@ class EngineProvider extends ChangeNotifier {
238240
(_) => _fetchEvents(),
239241
);
240242

243+
// Health watchdog: if the engine is killed externally (Task Manager) or
244+
// stops responding, attempt a bounded restart/reconnect.
245+
_engineHealthWatchdog?.cancel();
246+
_engineHealthWatchdog = Timer.periodic(
247+
const Duration(seconds: 6),
248+
(_) => unawaited(_engineHealthTick()),
249+
);
250+
241251
// Stay "disconnected" in UI until first live frame arrives (avoids false
242252
// positive if the socket dies immediately after subscribe).
243253
_connected = false;
@@ -262,6 +272,26 @@ class EngineProvider extends ChangeNotifier {
262272
}
263273
}
264274

275+
Future<void> _engineHealthTick() async {
276+
// Avoid piling reconnects; reuse the same bootstrap + connect path.
277+
if (_recoveringLive) return;
278+
if (_bootstrapErrorPending) return;
279+
if (_reconnectTimer != null) return;
280+
281+
try {
282+
final j = await _service.getHealth();
283+
if (j != null && j['engine'] == true) return;
284+
} catch (_) {
285+
// treat as unhealthy
286+
}
287+
288+
// Engine is down or unhealthy: restart/reconnect.
289+
_connected = false;
290+
_connectionError = 'Engine stopped; restarting…';
291+
notifyListeners();
292+
_scheduleReconnect();
293+
}
294+
265295
void _checkLiveDataStale() {
266296
if (_liveSub == null || _recoveringLive) return;
267297
final reference = _lastLiveStateAt ?? _wsSubscribeAt;
@@ -459,14 +489,16 @@ class EngineProvider extends ChangeNotifier {
459489
@override
460490
void dispose() {
461491
_liveDataWatchdog?.cancel();
492+
_engineHealthWatchdog?.cancel();
462493
_liveSub?.cancel();
463494
_alertSub?.cancel();
464495
_processFetchTimer?.cancel();
465496
_eventFetchTimer?.cancel();
466497
_reconnectTimer?.cancel();
467498
_cooldownTicker?.cancel();
468499
_service.dispose();
469-
unawaited(EngineBundledLauncher.stopOwnedEngine());
500+
// Do NOT kill the engine on app close. The engine is designed to be a
501+
// background component and should survive dashboard restarts.
470502
super.dispose();
471503
}
472504
}

dashboard/lib/services/engine_bundled_launcher.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class EngineBundledLauncher {
3030
static bool _engineStartedByApp = false;
3131
static bool _busy = false;
3232

33-
static const Duration _healthWindow = Duration(seconds: 25);
33+
static const Duration _healthWindow = Duration(seconds: 45);
3434
static const Duration _healthTick = Duration(milliseconds: 500);
3535
static const int _maxRestartCycles = 3;
3636

@@ -223,6 +223,15 @@ class EngineBundledLauncher {
223223
_ownedProcess = null;
224224
_engineStartedByApp = false;
225225
if (p == null) return;
226+
if (Platform.isWindows) {
227+
try {
228+
await Process.run(
229+
'taskkill',
230+
<String>['/PID', '${p.pid}', '/T', '/F'],
231+
runInShell: true,
232+
);
233+
} catch (_) {}
234+
}
226235
try {
227236
p.kill();
228237
await Future<void>.delayed(const Duration(milliseconds: 400));

dashboard/lib/services/engine_config_store.dart

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ class EngineConfigStore {
2121
if (Platform.isWindows) {
2222
final base = Platform.environment['LOCALAPPDATA'];
2323
if (base != null && base.trim().isNotEmpty) {
24-
return Directory(
25-
'${base.trim()}${Platform.pathSeparator}SentraCore');
24+
return Directory('${base.trim()}${Platform.pathSeparator}SentraCore');
2625
}
2726
}
2827
if (Platform.isMacOS) {
@@ -35,8 +34,7 @@ class EngineConfigStore {
3534
// Linux / fallback
3635
final xdg = Platform.environment['XDG_CONFIG_HOME'];
3736
if (xdg != null && xdg.trim().isNotEmpty) {
38-
return Directory(
39-
'${xdg.trim()}${Platform.pathSeparator}sentracore');
37+
return Directory('${xdg.trim()}${Platform.pathSeparator}sentracore');
4038
}
4139
final home = Platform.environment['HOME'] ?? '';
4240
if (home.trim().isNotEmpty) {

engine/engine_config.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ def engine_config_path() -> Path:
5858
return Path(local) / "SentraCore" / "engine-config.json"
5959
if system().lower() == "darwin":
6060
home = Path.home()
61-
return home / "Library" / "Application Support" / "SentraCore" / "engine-config.json"
61+
return (
62+
home
63+
/ "Library"
64+
/ "Application Support"
65+
/ "SentraCore"
66+
/ "engine-config.json"
67+
)
6268
xdg = os.environ.get("XDG_CONFIG_HOME")
6369
if xdg:
6470
return Path(xdg) / "sentracore" / "engine-config.json"

installer/sentracore.iss

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,14 @@ Name: "{group}\Uninstall SentraCore"; Filename: "{uninstallexe}"
3232

3333
[Tasks]
3434
Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional icons:"
35-
Name: "postinstallengine"; Description: "Start SentraCore Engine once after installation"; GroupDescription: "After installation:"
36-
Name: "startup"; Description: "Run SentraCore Engine automatically when Windows starts"; GroupDescription: "Background Service:"
3735

3836
[Registry]
39-
; Add the Engine to Windows Startup if the user selected the task
40-
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "SentraCoreEngine"; ValueData: """{app}\SentraCoreEngine.exe"""; Tasks: startup
37+
; Always run engine on user login (background)
38+
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "SentraCoreEngine"; ValueData: """{app}\SentraCoreEngine.exe"""
4139

4240
[Run]
43-
; Optional: first engine start (dashboard can still start the engine later if unchecked)
44-
Filename: "{app}\SentraCoreEngine.exe"; Description: "Start SentraCore Engine now"; Flags: nowait postinstall runhidden; Tasks: postinstallengine
41+
; Always start engine after install (background)
42+
Filename: "{app}\SentraCoreEngine.exe"; Flags: nowait postinstall runhidden
4543
; Optionally launch the dashboard
4644
Filename: "{app}\sentracore_dashboard.exe"; Description: "Launch SentraCore Dashboard"; Flags: nowait postinstall
4745

0 commit comments

Comments
 (0)