blob: 10ea06ef5884ac1988f039a9ec6bb69a0e8adada [file]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:isolate';
import 'package:flutter_tools_core/flutter_tools_core.dart';
import 'package:flutter_tools_extension/flutter_tools_extension.dart';
import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
import 'package:meta/meta.dart';
import 'package:stream_channel/isolate_channel.dart';
import '../base/logger.dart';
/// Typedef for an in-process extension isolate entrypoint function.
///
/// Used by unit tests to spawn lightweight in-process extension isolates via
/// [ExtensionConnection.spawn] without compiling a `.dill` kernel file, and
/// during transitional bootstrap before dynamic project discovery is wired via
/// [ExtensionConnection.spawnUri].
typedef ExtensionEntryPoint = void Function(SendPort sendPort);
/// Represents an active host-side connection to a running tool extension isolate.
class ExtensionConnection {
ExtensionConnection._({
required this.capabilities,
required this._errorPort,
required this._exitPort,
required this.extensionName,
required this._isolate,
required this._logger,
required this._peer,
required this._receivePort,
});
/// Fallback extension name used when an extension does not report one in its capabilities.
static const defaultExtensionName = 'unknown_extension';
Isolate? _isolate;
/// Port receiving uncaught async errors (`[String error, String? stackTrace]`)
/// from the extension isolate.
final ReceivePort _errorPort;
/// Port notified when the extension isolate terminates so the host can close
/// [_receivePort] and fail any in-flight RPCs instead of hanging.
final ReceivePort _exitPort;
final Logger _logger;
final json_rpc.Peer _peer;
final ReceivePort _receivePort;
final _notificationControllers = <String, StreamController<Map<String, Object?>>>{};
/// The capabilities and supported service namespaces of the extension.
final ToolExtensionCapabilities capabilities;
/// The resolved package or extension name of the connected extension.
final String extensionName;
bool _isDisposed = false;
/// Whether this connection has been disposed.
bool get isDisposed => _isDisposed;
/// Handles uncaught async errors from the extension isolate after handshake.
///
/// Per [Isolate.addErrorListener], `dart:isolate` delivers uncaught errors on
/// the `onError` port as a two-element `List<Object?>` containing
/// `[String errorDescription, String? stackTrace]`. Logging them here surfaces
/// uncaught extension isolate errors that occur outside an active JSON-RPC
/// request handler.
void _handleIsolateError(Object? error) {
if (error case [final Object? errorDescription, final Object? stackTrace]) {
_logger.printError('Extension isolate "$extensionName" unhandled error: $errorDescription');
if (stackTrace != null) {
_logger.printTrace('$stackTrace');
}
} else {
_logger.printError('Extension isolate "$extensionName" unhandled error: $error');
}
}
/// Handles unexpected extension isolate termination after handshake.
///
/// `IsolateChannel` (backed by a `ReceivePort`/`SendPort` pair) does not
/// automatically close when the remote isolate exits. Closing [_receivePort]
/// when the `onExit` port fires causes the JSON-RPC [json_rpc.Peer] to close
/// immediately, failing any in-flight [sendRequest] calls (such as
/// long-running `timeout: null` build requests) with a [StateError] and
/// closing active notification streams instead of hanging indefinitely.
void _handleIsolateExit() {
_logger.printTrace('Extension isolate "$extensionName" exited unexpectedly.');
unawaited(dispose());
}
/// Returns a broadcast stream of JSON-RPC 2.0 notifications sent by the
/// extension isolate for [method].
Stream<Map<String, Object?>> onNotification(String method) {
if (_isDisposed) {
throw StateError('ExtensionConnection has been disposed.');
}
final StreamController<Map<String, Object?>>
controller = _notificationControllers.putIfAbsent(method, () {
final newController = StreamController<Map<String, Object?>>.broadcast();
_peer.registerMethod(method, (json_rpc.Parameters params) {
if (newController.isClosed) {
return;
}
final Object? rawValue = params.value;
if (rawValue is! Map<String, Object?>) {
_logger.printWarning(
'Extension "$extensionName" sent malformed notification for "$method": expected Map<String, Object?>.',
);
return;
}
newController.add(rawValue);
});
return newController;
});
return controller.stream;
}
/// Sends an RPC request to the extension isolate.
Future<Object?> sendRequest(
String method, {
Object? params,
Duration? timeout = const Duration(seconds: 5),
}) async {
if (_isDisposed) {
throw StateError('ExtensionConnection has been disposed.');
}
_logger.printTrace('ExtensionConnection sending RPC request "$method"...');
try {
final Future<Object?> request = _peer.sendRequest(method, params);
final Object? result = await (timeout != null ? request.timeout(timeout) : request);
_logger.printTrace('ExtensionConnection received response for RPC request "$method".');
return result;
} catch (error) {
_logger.printTrace('ExtensionConnection RPC request "$method" failed with error: $error');
rethrow;
}
}
/// Spawns an in-process extension isolate from [entryPoint] and completes the
/// capability handshake.
///
/// Used by unit tests to exercise extension protocol behavior in-process
/// without compiling a `.dill` kernel file, and during transitional bootstrap
/// before dynamic project discovery is wired via [spawnUri].
@visibleForTesting
static Future<ExtensionConnection> spawn(
ExtensionEntryPoint entryPoint, {
required Logger logger,
String? extensionName,
Duration timeout = const Duration(seconds: 5),
}) {
logger.printTrace('ExtensionConnection spawning extension isolate...');
return _spawnAndHandshake(
logger: logger,
extensionName: extensionName,
timeout: timeout,
isolateLauncher: (SendPort sendPort, SendPort onError, SendPort onExit) {
return Isolate.spawn(entryPoint, sendPort, onError: onError, onExit: onExit);
},
);
}
/// Spawns an extension isolate dynamically from [entrypointUri] (a compiled
/// `.dill` Kernel URI or `.dart` script URI) using [packageConfigUri] and
/// completes the capability handshake.
///
/// When [expectedConfig] is provided, validates that the runtime
/// [ToolExtensionCapabilities] reported by `extension.getCapabilities` match
/// the static declarations in `extension/flutter_tools/config.yaml`,
/// disposing the connection and throwing a [StateError] if they drift.
static Future<ExtensionConnection> spawnUri(
Uri entrypointUri, {
required Logger logger,
required Uri packageConfigUri,
List<String> args = const <String>[],
ExtensionPackageConfig? expectedConfig,
String? extensionName,
Duration timeout = const Duration(seconds: 5),
}) async {
logger.printTrace(
'ExtensionConnection spawning extension isolate from URI "$entrypointUri"...',
);
final ExtensionConnection connection = await _spawnAndHandshake(
logger: logger,
extensionName: extensionName ?? expectedConfig?.name,
timeout: timeout,
isolateLauncher: (SendPort sendPort, SendPort onError, SendPort onExit) {
return Isolate.spawnUri(
entrypointUri,
args,
sendPort,
packageConfig: packageConfigUri,
onError: onError,
onExit: onExit,
);
},
);
if (expectedConfig != null) {
try {
connection._validateCapabilitiesMatchConfig(expectedConfig);
} on Object {
await connection.dispose();
rethrow;
}
}
return connection;
}
void _validateCapabilitiesMatchConfig(ExtensionPackageConfig expectedConfig) {
final Set<String> actualServices = capabilities.services.toSet();
if (actualServices.length != expectedConfig.services.length ||
!actualServices.containsAll(expectedConfig.services)) {
throw StateError(
'Extension "$extensionName" capabilities mismatch: '
'extension/flutter_tools/config.yaml declares services '
'${expectedConfig.services.toList()}, '
'but ToolExtensionEntryPoint.run registered ${capabilities.services}.',
);
}
if (expectedConfig.hostPlatforms.isNotEmpty) {
final Set<String> actualPlatforms = capabilities.supportedPlatforms;
if (actualPlatforms.length != expectedConfig.hostPlatforms.length ||
!actualPlatforms.containsAll(expectedConfig.hostPlatforms)) {
throw StateError(
'Extension "$extensionName" capabilities mismatch: '
'extension/flutter_tools/config.yaml declares host_platforms '
'${expectedConfig.hostPlatforms.toList()}, '
'but ToolExtensionEntryPoint.run registered supportedPlatforms '
'${actualPlatforms.toList()}.',
);
}
}
}
static Future<ExtensionConnection> _spawnAndHandshake({
required Future<Isolate> Function(SendPort sendPort, SendPort onError, SendPort onExit)
isolateLauncher,
required Logger logger,
String? extensionName,
Duration timeout = const Duration(seconds: 5),
}) async {
final receivePort = ReceivePort();
final errorPort = ReceivePort();
final exitPort = ReceivePort();
final errorCompleter = Completer<Never>();
ExtensionConnection? connection;
// Isolate.spawn delivers uncaught isolate errors as a two-element list:
// `[String errorDescription, String? stackTrace]`.
errorPort.listen((Object? error) {
if (connection case final ExtensionConnection activeConnection) {
activeConnection._handleIsolateError(error);
return;
}
if (!errorCompleter.isCompleted) {
final Object? errorDescription = switch (error) {
[final Object? description, _] => description,
_ => error,
};
errorCompleter.completeError(StateError('Extension isolate error: $errorDescription'));
}
});
exitPort.listen((Object? _) {
if (connection case final ExtensionConnection activeConnection) {
activeConnection._handleIsolateExit();
return;
}
if (!errorCompleter.isCompleted) {
errorCompleter.completeError(
StateError('Extension isolate exited unexpectedly before handshake completed.'),
);
}
});
Isolate? isolate;
try {
isolate = await isolateLauncher(receivePort.sendPort, errorPort.sendPort, exitPort.sendPort);
logger.printTrace('ExtensionConnection isolate spawned; connecting IsolateChannel...');
final channel = IsolateChannel<Object?>.connectReceive(receivePort);
final peer = json_rpc.Peer.withoutJson(channel);
unawaited(peer.listen());
logger.printTrace('ExtensionConnection querying extension.getCapabilities...');
final Object? responseObj = await Future.any<Object?>([
peer.sendRequest(ToolExtensionEntryPoint.getCapabilitiesMethod),
errorCompleter.future,
]).timeout(timeout);
if (responseObj is! Map<String, Object?>) {
throw StateError(
'Extension handshake failed: extension.getCapabilities did not return a Map.',
);
}
final capabilities = ToolExtensionCapabilities.fromJson(responseObj);
logger.printTrace(
'ExtensionConnection handshake complete. Capabilities: ${capabilities.services}',
);
return connection = ExtensionConnection._(
capabilities: capabilities,
errorPort: errorPort,
exitPort: exitPort,
extensionName: extensionName ?? capabilities.extensionName ?? defaultExtensionName,
isolate: isolate,
logger: logger,
peer: peer,
receivePort: receivePort,
);
} on TimeoutException {
logger.printTrace('ExtensionConnection handshake timed out.');
receivePort.close();
errorPort.close();
exitPort.close();
isolate?.kill(priority: Isolate.immediate);
throw TimeoutException('Handshake with tool extension isolate timed out.');
} on Object catch (error) {
logger.printTrace('ExtensionConnection spawn failed with error: $error');
receivePort.close();
errorPort.close();
exitPort.close();
isolate?.kill(priority: Isolate.immediate);
rethrow;
}
}
/// Disposes the extension isolate connection.
Future<void> dispose() async {
if (_isDisposed) {
return;
}
_isDisposed = true;
_logger.printTrace('ExtensionConnection disposing isolate connection.');
_errorPort.close();
_exitPort.close();
_receivePort.close();
for (final StreamController<Map<String, Object?>> controller
in _notificationControllers.values) {
await controller.close();
}
_notificationControllers.clear();
try {
await _peer.close();
} on Object catch (error) {
_logger.printTrace('Error closing extension connection peer: $error');
} finally {
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
}
}
}
/// Discovers and manages active tool extension isolate connections.
class ExtensionDiscovery {
/// Creates an [ExtensionDiscovery] instance with required [_logger].
ExtensionDiscovery({required this._logger});
final _connections = <ExtensionConnection>[];
final Logger _logger;
/// Active extension connections.
List<ExtensionConnection> get connections => List<ExtensionConnection>.unmodifiable(_connections);
/// Registers an active [connection].
void registerConnection(ExtensionConnection connection) {
_logger.printTrace('ExtensionDiscovery registering active connection.');
_connections.add(connection);
}
/// Registers multiple active [connections].
void registerConnections(Iterable<ExtensionConnection> connections) {
_logger.printTrace('ExtensionDiscovery registering ${connections.length} connection(s).');
_connections.addAll(connections);
}
/// Disposes all registered extension isolate connections.
Future<void> dispose() async {
_logger.printTrace('ExtensionDiscovery disposing all registered connections.');
for (final ExtensionConnection connection in _connections) {
await connection.dispose();
}
_connections.clear();
}
}