Skip to content

Commit f308c6f

Browse files
feat: enhance engine connection handling with dynamic port resolution and user preferences support
1 parent c11314b commit f308c6f

9 files changed

Lines changed: 262 additions & 80 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
# Runtime data (machine-specific, auto-generated)
66
engine/datastore/baseline.json
7+
engine/datastore/user_preferences.json
78

89
# Flutter dashboard build artifacts
910
dashboard/.dart_tool/

dashboard/lib/providers/engine_provider.dart

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class EngineProvider extends ChangeNotifier {
1919
required DesktopNotificationService notifications,
2020
}) : _settings = settings,
2121
_notifications = notifications {
22-
_service = EngineService();
22+
_service = EngineService(port: settings.lastEngineHttpPort);
2323
}
2424

2525
final SettingsProvider _settings;
@@ -103,7 +103,7 @@ class EngineProvider extends ChangeNotifier {
103103
_reconnectTimer?.cancel();
104104
_cooldownTicker?.cancel();
105105
_service.dispose();
106-
_service = EngineService();
106+
_service = EngineService(port: _settings.lastEngineHttpPort);
107107
_connected = false;
108108
_currentState = null;
109109
_cooldownDeadline = null;
@@ -122,8 +122,7 @@ class EngineProvider extends ChangeNotifier {
122122
}
123123

124124
final out = await EngineBundledLauncher.ensureReady(
125-
host: EngineService.defaultHost,
126-
port: EngineService.defaultPort,
125+
preferredPort: _settings.lastEngineHttpPort,
127126
);
128127

129128
if (!out.success && (out.message?.isNotEmpty ?? false)) {
@@ -137,6 +136,14 @@ class EngineProvider extends ChangeNotifier {
137136
}
138137
}
139138

139+
if (out.success) {
140+
if (out.activePort != _service.port) {
141+
_service.dispose();
142+
_service = EngineService(port: out.activePort);
143+
}
144+
await _settings.setLastEngineHttpPort(out.activePort);
145+
}
146+
140147
_tryConnect();
141148
}
142149

dashboard/lib/providers/settings_provider.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart';
55
class SettingsProvider extends ChangeNotifier {
66
static const _kDesktopNotif = 'desktop_notifications';
77
static const _kTheme = 'theme_mode';
8+
static const _kLastEnginePort = 'engine_last_http_port';
89
static const _kAlertCpu = 'alert_cpu_percent';
910
static const _kAlertMem = 'alert_memory_percent';
1011
static const _kAlertDisk = 'alert_disk_pressure';
@@ -19,6 +20,7 @@ class SettingsProvider extends ChangeNotifier {
1920
double _alertDiskPressure = 80;
2021
bool _safeguardEnabled = false;
2122
String _safeguardProcessNames = '';
23+
int _lastEngineHttpPort = 8740;
2224

2325
ThemeMode get themeMode => _themeMode;
2426
bool get isDarkMode => _themeMode == ThemeMode.dark;
@@ -29,6 +31,7 @@ class SettingsProvider extends ChangeNotifier {
2931
double get alertDiskPressure => _alertDiskPressure;
3032
bool get safeguardEnabled => _safeguardEnabled;
3133
String get safeguardProcessNames => _safeguardProcessNames;
34+
int get lastEngineHttpPort => _lastEngineHttpPort;
3235

3336
Future<void> load() async {
3437
final p = await SharedPreferences.getInstance();
@@ -46,6 +49,7 @@ class SettingsProvider extends ChangeNotifier {
4649
_alertDiskPressure = p.getDouble(_kAlertDisk) ?? 80;
4750
_safeguardEnabled = p.getBool(_kSafeguardOn) ?? false;
4851
_safeguardProcessNames = p.getString(_kSafeguardNames) ?? '';
52+
_lastEngineHttpPort = p.getInt(_kLastEnginePort) ?? 8740;
4953
notifyListeners();
5054
}
5155

@@ -63,6 +67,14 @@ class SettingsProvider extends ChangeNotifier {
6367
await p.setDouble(_kAlertDisk, _alertDiskPressure);
6468
await p.setBool(_kSafeguardOn, _safeguardEnabled);
6569
await p.setString(_kSafeguardNames, _safeguardProcessNames);
70+
await p.setInt(_kLastEnginePort, _lastEngineHttpPort);
71+
}
72+
73+
Future<void> setLastEngineHttpPort(int port) async {
74+
_lastEngineHttpPort = port.clamp(1, 65535);
75+
final p = await SharedPreferences.getInstance();
76+
await p.setInt(_kLastEnginePort, _lastEngineHttpPort);
77+
notifyListeners();
6678
}
6779

6880
void setDesktopNotifications(bool v) {
Lines changed: 45 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,27 @@
1-
import 'dart:convert';
21
import 'dart:io';
32

4-
import 'package:http/http.dart' as http;
3+
import 'package:sentracore_dashboard/services/engine_port_resolver.dart';
4+
import 'package:sentracore_dashboard/services/engine_service.dart';
55

66
/// Outcome of trying to ensure the packaged engine is running (Windows install).
77
class EngineBootstrapOutcome {
88
final bool success;
99
final String? message;
10+
/// Port where [GET /api/v1/health] succeeded (or fallback when skipped).
11+
final int activePort;
1012

11-
const EngineBootstrapOutcome({required this.success, this.message});
13+
const EngineBootstrapOutcome({
14+
required this.success,
15+
this.message,
16+
this.activePort = EngineService.defaultPort,
17+
});
1218
}
1319

1420
/// Starts [SentraCoreEngine.exe] next to the dashboard when installed via Inno Setup,
15-
/// then waits until the HTTP API reports the engine is initialized.
16-
///
17-
/// Skipped when:
18-
/// - Not Windows
19-
/// - [host] is not a local loopback address (remote engine)
20-
/// - No `SentraCoreEngine.exe` beside this process (typical `flutter run` dev layout)
21+
/// then waits until the HTTP API is up on whatever port the engine chose (8740+).
2122
class EngineBundledLauncher {
2223
EngineBundledLauncher._();
2324

24-
static bool _isLocalHost(String host) {
25-
final h = host.toLowerCase().trim();
26-
return h == '127.0.0.1' || h == 'localhost' || h == '::1';
27-
}
28-
2925
/// Path to the packaged engine exe, or null if not present (e.g. dev builds).
3026
static String? bundledEngineExecutablePath() {
3127
if (!Platform.isWindows) return null;
@@ -35,73 +31,52 @@ class EngineBundledLauncher {
3531
return candidate.existsSync() ? candidate.path : null;
3632
}
3733

38-
static String _healthUrl(String host, int port) {
39-
final h = host.trim();
40-
final authority = h == '::1' ? '[::1]:$port' : '$h:$port';
41-
return 'http://$authority/api/v1/health';
42-
}
43-
44-
static Future<bool> _engineHealthy(String host, int port) async {
45-
try {
46-
final r = await http
47-
.get(Uri.parse(_healthUrl(host, port)))
48-
.timeout(const Duration(milliseconds: 900));
49-
if (r.statusCode != 200) return false;
50-
final j = jsonDecode(r.body) as Map<String, dynamic>;
51-
return j['engine'] == true;
52-
} catch (_) {
53-
return false;
54-
}
55-
}
56-
57-
/// Ensures the local engine process is up and responding, starting it if needed.
58-
static Future<EngineBootstrapOutcome> ensureReady({
59-
required String host,
60-
required int port,
61-
}) async {
62-
if (!_isLocalHost(host)) {
63-
return const EngineBootstrapOutcome(success: true);
64-
}
34+
/// Discover a running engine, optionally starting the Windows bundled exe first.
35+
static Future<EngineBootstrapOutcome> ensureReady({int? preferredPort}) async {
6536
if (!Platform.isWindows) {
66-
return const EngineBootstrapOutcome(success: true);
37+
final p = await EnginePortResolver.discoverPort(preferredPort: preferredPort);
38+
return EngineBootstrapOutcome(
39+
success: true,
40+
activePort: p ?? EngineService.defaultPort,
41+
);
6742
}
6843

69-
if (await _engineHealthy(host, port)) {
70-
return const EngineBootstrapOutcome(success: true);
44+
var port = await EnginePortResolver.discoverPort(preferredPort: preferredPort);
45+
if (port != null) {
46+
return EngineBootstrapOutcome(success: true, activePort: port);
7147
}
7248

7349
final exe = bundledEngineExecutablePath();
74-
if (exe == null) {
75-
return const EngineBootstrapOutcome(success: true);
76-
}
50+
if (exe != null) {
51+
try {
52+
await Process.start(
53+
exe,
54+
const <String>[],
55+
workingDirectory: File(exe).parent.path,
56+
mode: ProcessStartMode.detached,
57+
);
58+
} catch (e) {
59+
return EngineBootstrapOutcome(
60+
success: false,
61+
message: 'Could not start SentraCoreEngine.exe: $e',
62+
activePort: EngineService.defaultPort,
63+
);
64+
}
65+
66+
port = await EnginePortResolver.discoverPort(preferredPort: preferredPort);
67+
if (port != null) {
68+
return EngineBootstrapOutcome(success: true, activePort: port);
69+
}
7770

78-
try {
79-
await Process.start(
80-
exe,
81-
const <String>[],
82-
workingDirectory: File(exe).parent.path,
83-
mode: ProcessStartMode.detached,
84-
);
85-
} catch (e) {
8671
return EngineBootstrapOutcome(
8772
success: false,
88-
message: 'Could not start SentraCoreEngine.exe: $e',
73+
message:
74+
'Engine did not become ready. It may have crashed, or ports are blocked.',
75+
activePort: EngineService.defaultPort,
8976
);
9077
}
9178

92-
const step = Duration(milliseconds: 450);
93-
final deadline = DateTime.now().add(const Duration(seconds: 45));
94-
while (DateTime.now().isBefore(deadline)) {
95-
if (await _engineHealthy(host, port)) {
96-
return const EngineBootstrapOutcome(success: true);
97-
}
98-
await Future<void>.delayed(step);
99-
}
100-
101-
return EngineBootstrapOutcome(
102-
success: false,
103-
message: 'Engine did not become ready on port $port. '
104-
'It may have crashed, or another program is using that port.',
105-
);
79+
// Windows dev: no bundled exe — try default port only (same as legacy skip).
80+
return EngineBootstrapOutcome(success: true, activePort: EngineService.defaultPort);
10681
}
10782
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:http/http.dart' as http;
5+
import 'package:sentracore_dashboard/services/engine_service.dart';
6+
7+
/// Discovers which TCP port the local engine is listening on (8740+ if busy).
8+
class EnginePortResolver {
9+
EnginePortResolver._();
10+
11+
static String? _runtimeFilePath() {
12+
if (Platform.isWindows) {
13+
final la = Platform.environment['LOCALAPPDATA'];
14+
if (la == null || la.isEmpty) return null;
15+
return '$la${Platform.pathSeparator}SentraCore${Platform.pathSeparator}datastore${Platform.pathSeparator}engine_runtime.json';
16+
}
17+
final home = Platform.environment['HOME'];
18+
if (home != null && home.isNotEmpty) {
19+
return '$home/.local/share/SentraCore/datastore/engine_runtime.json';
20+
}
21+
return null;
22+
}
23+
24+
static int? _readPortFromRuntimeFile() {
25+
final path = _runtimeFilePath();
26+
if (path == null) return null;
27+
try {
28+
final f = File(path);
29+
if (!f.existsSync()) return null;
30+
final j = jsonDecode(f.readAsStringSync()) as Map<String, dynamic>;
31+
return (j['http_port'] as num?)?.toInt();
32+
} catch (_) {
33+
return null;
34+
}
35+
}
36+
37+
/// True when [GET /api/v1/health] reports the orchestrator is registered.
38+
static Future<bool> engineHealthy(String host, int port) async {
39+
try {
40+
final uri = Uri.parse('http://$host:$port/api/v1/health');
41+
final r = await http
42+
.get(uri)
43+
.timeout(const Duration(milliseconds: 900));
44+
if (r.statusCode != 200) return false;
45+
final j = jsonDecode(r.body) as Map<String, dynamic>;
46+
return j['engine'] == true;
47+
} catch (_) {
48+
return false;
49+
}
50+
}
51+
52+
/// Waits until the engine responds on some port (runtime file, cache, or scan).
53+
///
54+
/// [scanEndExclusive] caps a linear fallback scan; high ports are reached via
55+
/// `engine_runtime.json` written by the engine after bind.
56+
static Future<int?> discoverPort({
57+
int? preferredPort,
58+
int timeoutSeconds = 45,
59+
int scanStart = EngineService.defaultPort,
60+
int scanEndExclusive = 8900,
61+
}) async {
62+
const host = EngineService.defaultHost;
63+
final deadline = DateTime.now().add(Duration(seconds: timeoutSeconds));
64+
65+
while (DateTime.now().isBefore(deadline)) {
66+
if (preferredPort != null) {
67+
if (await engineHealthy(host, preferredPort)) {
68+
return preferredPort;
69+
}
70+
}
71+
72+
final fromFile = _readPortFromRuntimeFile();
73+
if (fromFile != null && await engineHealthy(host, fromFile)) {
74+
return fromFile;
75+
}
76+
77+
for (var p = scanStart; p < scanEndExclusive; p++) {
78+
if (await engineHealthy(host, p)) {
79+
return p;
80+
}
81+
}
82+
83+
await Future<void>.delayed(const Duration(milliseconds: 200));
84+
}
85+
return null;
86+
}
87+
}

docs/setup/engine_setup.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ On startup, the engine will:
5959
| `GET http://localhost:8740/api/v1/alerts` | Alert history with Root Cause Analysis |
6060
| `GET http://localhost:8740/api/v1/preferences` | User alert thresholds and safeguard process list |
6161
| `PUT http://localhost:8740/api/v1/preferences` | Update preferences (JSON body; persisted under datastore) |
62+
63+
**Dynamic HTTP port:** if `8740` is already in use, the engine binds the next free port up to `65535` and writes `engine_runtime.json` in the datastore (`http_host`, `http_port`). The Flutter dashboard discovers the active port via that file, a cached last-known port, and a short local scan starting at `8740`.
6264
| `WS ws://localhost:8740/ws/live` | Real-time state broadcast (WebSocket) |
6365

6466
---

0 commit comments

Comments
 (0)