vmServiceCall function
Sends a JSON-RPC request to the Flutter VM service over websocket. Returns the parsed JSON response. Throws AppDiedException when the app process is detected as dead, or on connection failure / timeout.
Implementation
Future<Map<String, dynamic>> vmServiceCall(
String method, {
Map<String, dynamic> params = const {},
Duration timeout = const Duration(seconds: 30),
}) async {
final uri = readVmUri();
if (uri == null || uri.isEmpty) {
throw StateError('VM service URI not found. Is the app running?');
}
// Pre-check: if the process is dead, short-circuit immediately without
// even attempting a WebSocket connection.
//
// On the macOS desktop *target* (not host), the app VM PID (fdb.app_pid)
// and the flutter-tools PID (fdb.pid) both live in the host process table,
// so we can check either. Prefer the app PID because it is the actual Dart
// VM process; fall back to the flutter-tools PID when fdb.app_pid has not
// been written yet.
//
// On Android and iOS targets the app VM PID from getVM lives inside the
// device / simulator process namespace and is NOT visible to the host macOS
// process table — kill -0 would always return false, producing false
// positives. On those targets skip the PID pre-check and rely on the
// connection-refused heuristic below.
if (_isMacOsTarget()) {
final pid = readAppPid() ?? readPid();
if (pid != null && !isProcessAlive(pid)) {
throw await buildAppDiedException(pid: pid);
}
}
final wsUri = uri.replaceFirst('http://', 'ws://').replaceFirst('https://', 'wss://');
WebSocket ws;
try {
ws = await WebSocket.connect(
wsUri,
customClient: HttpClient()..maxConnectionsPerHost = 1,
).timeout(const Duration(seconds: 5));
} on TimeoutException {
// Connection timed out — check if the process died in the meantime.
// Use app PID on the macOS target (most accurate); fall back to
// flutter-tools PID. Skip on Android/iOS where the PID is not on the host.
if (_isMacOsTarget()) {
final currentPid = readAppPid() ?? readPid();
if (currentPid != null && !isProcessAlive(currentPid)) {
throw await buildAppDiedException(pid: currentPid);
}
}
// Rethrow original so the caller sees a TimeoutException when the app
// is still nominally alive but the VM service is unreachable.
rethrow;
} catch (e) {
// Connection refused / OS error: the VM service is no longer accepting
// connections. This is the primary signal that the app has died — the
// PID liveness check alone is insufficient because fdb.pid stores the
// flutter-tools process PID, not the actual app process PID (fdb-bbu).
//
// We treat ANY connection-refused-like error as APP_DIED, since the VM
// service URI was written by `fdb launch` only when the app was healthy.
// If the app came back on a different port the URI would also be stale,
// but that scenario is handled by re-running `fdb launch`.
if (_isConnectionRefused(e)) {
// Prefer the app PID on macOS target (host-visible). On Android/iOS the
// app PID is device-namespace and not useful — use flutter-tools PID.
final pid = _isMacOsTarget() ? (readAppPid() ?? readPid()) : readPid();
throw await buildAppDiedException(pid: pid);
}
// For non-connection errors (e.g. bad URI, TLS issues) fall back to PID
// check before rethrowing (macOS target only — see pre-check rationale).
if (_isMacOsTarget()) {
final currentPid = readAppPid() ?? readPid();
if (currentPid != null && !isProcessAlive(currentPid)) {
throw await buildAppDiedException(pid: currentPid);
}
}
rethrow;
}
// Widget trees can be 500KB+, no built-in buffer size limit on dart:io WebSocket
// but we need to handle large responses properly.
final completer = Completer<Map<String, dynamic>>();
final requestId = DateTime.now().microsecondsSinceEpoch.toString();
final request = jsonEncode({
'jsonrpc': '2.0',
'id': requestId,
'method': method,
'params': params,
});
ws.listen(
(data) {
final response = jsonDecode(data as String) as Map<String, dynamic>;
if (response['id'] == requestId && !completer.isCompleted) {
completer.complete(response);
}
},
onError: (Object error) {
if (!completer.isCompleted) {
completer.completeError(error);
}
},
onDone: () {
if (!completer.isCompleted) {
// WebSocket closed before we got a response — check if the app died.
// We fire off the enrichment asynchronously and complete the error.
_buildDeadAppError().then((ex) {
if (!completer.isCompleted) completer.completeError(ex);
}).catchError((Object _) {
if (!completer.isCompleted) {
completer.completeError(StateError('WebSocket closed before response'));
}
});
}
},
);
ws.add(request);
try {
final response = await completer.future.timeout(timeout);
await ws.close();
return response;
} on TimeoutException {
await ws.close();
// Check if the process died during the wait (macOS target only).
if (_isMacOsTarget()) {
final currentPid = readAppPid() ?? readPid();
if (currentPid != null && !isProcessAlive(currentPid)) {
throw await buildAppDiedException(pid: currentPid);
}
}
rethrow;
} on AppDiedException {
// Already enriched — rethrow as-is.
try {
await ws.close();
} catch (_) {}
rethrow;
}
}