vmServiceCall function

Future<Map<String, dynamic>> vmServiceCall(
  1. String method, {
  2. Map<String, dynamic> params = const {},
  3. Duration timeout = const Duration(seconds: 30),
})

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;
  }
}