Files
sdk/pkg/dart_runtime_service/lib/src/clients.dart
T
Ben Konyi ae1b83869a [ Service ] Add support for DDS to package:dart_runtime_service_vm
The DartRuntimeService based VM service implementation now has support
for launching DDS instances and responding to _yieldControlToDDS RPC
invocations from DDS instances.

package:vm_service test suite is ~97% passing with this change.

TEST=Local testing.
Change-Id: I2f2f1b0926845134578f08d073ed7606f1fc4173
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/490320
Reviewed-by: Jessy Yameogo <yjessy@google.com>
Reviewed-by: Nicholas Shahan <nshahan@google.com>
2026-04-10 12:04:28 -07:00

308 lines
9.6 KiB
Dart

// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:stream_channel/stream_channel.dart';
import 'dart_runtime_service.dart';
import 'dart_runtime_service_backend.dart';
import 'dart_runtime_service_rpcs.dart';
import 'event_streams.dart';
import 'rpc_exceptions.dart';
import 'utils.dart';
typedef ServiceName = String;
typedef ServiceAlias = String;
typedef ServiceNameAliasPair = ({ServiceName service, ServiceAlias alias});
/// Represents a client that is connected to a service.
base class Client {
Client({
required StreamChannel<String> connection,
required UnmodifiableClientNamedLookup clients,
required EventStreamMethods eventStreamMethods,
required this.backend,
required this.artificial,
String? name,
}) {
_name = name ?? defaultClientName;
_clientPeer = json_rpc.Peer(connection, strictProtocolChecks: false);
_internalRpcs = DartRuntimeServiceRpcs(
clients: clients,
eventStreamMethods: eventStreamMethods,
client: this,
expressionEvaluator: backend.expressionEvaluator,
);
}
late final String namespace;
late json_rpc.Peer _clientPeer;
late final DartRuntimeServiceRpcs _internalRpcs;
final DartRuntimeServiceBackend backend;
/// If `true`, this client was created via
/// [DartRuntimeService.addArtificialClient].
///
/// [DartRuntimeServiceBackend]s sometimes need to be able to create clients
/// that aren't associated with an active connection to the service. For
/// example, the Dart VM provides native APIs to invoke service RPCs. This
/// can be implemented by manually creating a [StreamChannel] for native RPC
/// invocations to be added to, which is then used to create an artificial
/// client.
final bool artificial;
/// The logger to be used when handling requests from this client.
Logger get logger => Logger(toString());
/// A [Future] that completes when [close] is invoked.
late final Future<void> done;
Future<void> initialize({required String namespace}) {
logger.info('Initializing...');
this.namespace = namespace;
registerRpcHandlers();
done = _listen().then((_) {
logger.info('Client connection closed.');
// Cleanup stream subscription state when the client disconnects.
_internalRpcs.eventStreamMethods.onClientDisconnect(this);
});
logger.info('Initialization complete.');
return done;
}
/// Start receiving JSON RPC requests from the client.
///
/// Returned future completes when the peer is closed.
Future<void> _listen() => _clientPeer.listen();
/// Called if the connection to the client should be closed.
@mustCallSuper
Future<void> close() async {
logger.info('Cleaning up.');
await _clientPeer.close();
}
@mustCallSuper
void registerRpcHandlers() {
_internalRpcs
..addBackendRpcs(backend: backend)
..registerRpcsWithPeer(_clientPeer)
..registerServiceExtensionForwarder(_clientPeer)
..registerBackendFallbacks(_clientPeer);
}
/// Attempts to register a [service] to be provided by this client.
///
/// [alias] is a human-readable description of the provided service.
///
/// If [service] is already registered with this client, an error is
/// returned.
bool registerService({
required ServiceName service,
required ServiceAlias alias,
}) {
if (hasService(service)) {
logger.info("Service '$service' is already registered by this client.");
return false;
}
logger.info(
"Successfully registered service '$service' as "
"'$namespace.$service'.",
);
_services[service] = alias;
_internalRpcs.eventStreamMethods.sendServiceRegisteredEvent(
this,
service,
alias,
);
return true;
}
/// Returns true if [service] has already been registered by this client.
bool hasService(String service) => _services.containsKey(service);
/// Invokes a JSON-RPC [method] provided by this client.
Future<RpcResponse> sendRequest({
required String method,
Map<String, Object?>? parameters,
}) async {
if (_clientPeer.isClosed) {
RpcException.serviceDisappeared.throwException();
}
try {
return await _clientPeer.sendRequest(method, parameters) as RpcResponse;
// ignore: avoid_catching_errors
} on StateError {
RpcException.serviceDisappeared.throwException();
}
}
/// Invokes a JSON-RPC [method] provided by this client, ignoring the
/// response.
void sendNotification({
required String method,
Map<String, Object?>? parameters,
}) {
if (_clientPeer.isClosed) {
RpcException.serviceDisappeared.throwException();
}
try {
_clientPeer.sendNotification(method, parameters);
// ignore: avoid_catching_errors
} on StateError {
RpcException.serviceDisappeared.throwException();
}
}
/// The set of services registered by this [Client].
Iterable<ServiceNameAliasPair> get services =>
_services.entries.map((e) => (service: e.key, alias: e.value));
final _services = <ServiceName, ServiceAlias>{};
static int _idCounter = 0;
final int _id = ++_idCounter;
/// The name given to the client upon its creation.
String get defaultClientName => 'client$_id';
/// The current name associated with this client.
String get name => _name;
/// Sets the name associated with this client.
///
/// If [n] is null, the client name is reset to [defaultClientName].
void setName(String? n) {
final updated = n ?? defaultClientName;
logger.info('Changing client name to $updated.');
_name = updated;
}
late String _name;
@override
String toString() => 'Client ($name)';
}
/// An interface that allows for controlling whether or not new [Client]
/// connections should be accepted or rejected.
abstract interface class ClientConnectionController {
/// Accept connection requests from new [Client]s.
void acceptConnections();
/// Reject connection requests from new [Client]s, redirecting them to
/// connect to [redirectUri] instead.
void rejectConnections({required Uri redirectUri});
}
/// Used for keeping track and managing clients that are connected to a given
/// service.
///
/// Call [addClient] when a client connects to your service.
base class ClientManager implements ClientConnectionController {
ClientManager({required this.backend, required this.eventStreamMethods});
static const _kServicePrologue = 's';
final DartRuntimeServiceBackend backend;
final EventStreamMethods eventStreamMethods;
final _logger = Logger('$ClientManager');
/// Returns `true` if new [Client] connections should be accepted.
///
/// If `false`, [redirectUri] will be non-null and should be included in a
/// redirect response.
bool get acceptNewConnections => redirectUri == null;
/// The [Uri] pointing to the service that [Client]s should attempt to connect
/// to.
///
/// Returns `null` if [acceptNewConnections] is `true`.
Uri? get redirectUri => _redirectUri;
Uri? _redirectUri;
/// The set of [Client]s currently connected to the service.
///
/// Each client is assigned a unique identifier, prefixed with
/// [_kServicePrologue] (e.g., 's1'). This identifier is used when invoking
/// service extensions registered by the client to indicate which client
/// is responsible for handling the service extension invocation.
UnmodifiableClientNamedLookup get clients =>
UnmodifiableClientNamedLookup(_clients);
final _clients = ClientNamedLookup(prefix: _kServicePrologue);
@override
void acceptConnections() {
_redirectUri = null;
_logger.info('Accepting new connections.');
}
@override
void rejectConnections({required Uri redirectUri}) {
_redirectUri = redirectUri;
_logger.info(
'No longer accepting new connections. Redirecting connections to '
'$redirectUri.',
);
}
/// Creates a [Client] from [connection] and adds it to the list of connected
/// clients.
///
/// This should be called when a client connects to the service.
@mustCallSuper
Client addClient({
required StreamChannel<String> connection,
String? name,
bool artificial = false,
}) {
final client = Client(
connection: connection,
clients: clients,
eventStreamMethods: eventStreamMethods,
backend: backend,
name: name,
artificial: artificial,
);
final namespace = _clients.add(client);
client.initialize(namespace: namespace).then((_) {
// Remove the client from the clients list when it disconnects.
removeClient(client);
});
return client;
}
/// Removes [client] from the list of connected clients.
///
/// This is called when the client disconnects from the service and should
/// not be invoked manually.
@mustCallSuper
@visibleForOverriding
void removeClient(Client client) {
if (_clients.contains(client)) {
_clients.remove(client);
}
}
/// Cleans up clients that are still connected by calling [Client.close] on
/// all of them.
Future<void> shutdown() async {
// Close all incoming websocket connections.
final futures = <Future<void>>[];
// Copy `clients` to guard against modification while iterating.
for (final client in _clients.toList()) {
futures.add(
Future.sync(() => removeClient(client)).whenComplete(client.close),
);
}
await Future.wait(futures);
// Reset the ID counter so logs in tests are consistent.
Client._idCounter = 0;
}
}