[ Service ] Add HTTP and DevFS support to package:dart_runtime_service

This change adds support for invoking RPCs via HTTP requests, allowing
for interacting with the service without needing to establish a web
socket connection.

This change also adds support for the development file system, otherwise
known as DevFS. DevFS is a (currently) undocumented feature provided by
the VM service that gives clients limited file system access within a
directory contained in the system's temp directory. This is currently
used by Flutter to push kernel files to the device when performing a hot
reload.

The DevFS implementation for package:dart_runtime_service_vm removes
the long deprecated support for `path` parameters, leaving `uri`s as
the only supported format for specifying file system types. Existing
DevFS tests have been updated to replace `path` with `uri` in
preparation for dart_runtime_service_vm becoming the new default VM
service.

This change brings the package:vm_service test suite pass rate to ~95%.

Change-Id: Ib7d95db5788c37408d3dec79926ca7206660a43f
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/488280
Reviewed-by: Jessy Yameogo <yjessy@google.com>
Reviewed-by: Nicholas Shahan <nshahan@google.com>
Commit-Queue: Ben Konyi <bkonyi@google.com>
This commit is contained in:
Ben Konyi
2026-03-20 15:23:38 -07:00
committed by Commit Queue
parent 2a3e95bdb6
commit 086cb1a441
17 changed files with 748 additions and 270 deletions
@@ -11,6 +11,7 @@ export 'src/dart_runtime_service_rpcs.dart'
RpcHandlerWithNoParameters,
RpcHandlerWithParameters,
ServiceRpcHandler;
export 'src/devfs.dart';
export 'src/event_streams.dart';
export 'src/exceptions.dart';
export 'src/expression_evaluator.dart';
@@ -177,11 +177,11 @@ class DartRuntimeService {
/// Creates an artificial client to process JSON-RPC requests from
/// non-standard sources (e.g., from native code).
void addArtificialClient({
Client addArtificialClient({
required StreamChannel<String> connection,
required String name,
}) {
clientManager.addClient(connection: connection, name: name);
return clientManager.addClient(connection: connection, name: name);
}
Future<void> _startServer() async {
@@ -272,6 +272,11 @@ class DartRuntimeService {
);
}
if (!config.disableOriginCheck) {
_logger.info('Origin checks are enabled. Adding CORS check handler.');
pipeline = pipeline.addMiddleware(originCheckMiddleware(frontend: this));
}
var handlerCascade = shelf.Cascade();
if (config.sseHandlerPath != null) {
_logger.info(
@@ -294,6 +299,9 @@ class DartRuntimeService {
webSocketClientHandler(clientManager: clientManager),
);
_logger.info('HTTP requests are accepted. Adding HTTP request handler.');
handlerCascade = handlerCascade.add(httpRequestHandler(frontend: this));
_logger.info('Shelf handlers generated.');
return pipeline.addHandler(handlerCascade.handler);
}
@@ -2,9 +2,11 @@
// 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 'dart:async';
import 'dart:collection';
import 'package:meta/meta.dart';
import 'package:shelf/shelf.dart';
import 'dart_runtime_service.dart';
import 'dart_runtime_service_rpcs.dart';
@@ -12,6 +14,8 @@ import 'event_streams.dart';
import 'expression_evaluator.dart';
import 'isolate_manager.dart';
typedef OptionalHandler = FutureOr<Response?> Function(Request);
/// A backend implementation of a service used to inject non-common
/// functionality into a [DartRuntimeService].
abstract class DartRuntimeServiceBackend<IM extends IsolateManager> {
@@ -84,4 +88,13 @@ abstract class DartRuntimeServiceBackend<IM extends IsolateManager> {
/// any registered RPCs or service extensions provided by other clients.
UnmodifiableListView<RpcHandlerWithParameters> get fallbacks =>
UnmodifiableListView(const []);
/// A custom handler for handling HTTP requests.
///
/// This handler is invoked before attempting to execute the HTTP request as
/// an RPC invocation or performing a redirection to a developer tool (e.g.,
/// DevTools). Returning null from the handler indicates that the request was
/// not handled by the custom handler.
OptionalHandler get httpHandler =>
(_) => null;
}
@@ -10,6 +10,7 @@ class DartRuntimeServiceOptions {
this.enableLogging = false,
this.port = 0,
this.disableAuthCodes = false,
this.disableOriginCheck = false,
this.sseHandlerPath,
this.autoStart = true,
});
@@ -28,6 +29,11 @@ class DartRuntimeServiceOptions {
/// Defaults to false.
final bool disableAuthCodes;
/// If true, CORS requests to the service will be accepted.
///
/// Defaults to false.
final bool disableOriginCheck;
/// If non-null, allow for SSE connections to be established at
/// [sseHandlerPath].
///
@@ -41,6 +47,7 @@ class DartRuntimeServiceOptions {
bool? enableLogging,
int? port,
bool? disableAuthCodes,
bool? disableOriginCheck,
String? sseHandlerPath,
bool? autoStart,
}) {
@@ -48,6 +55,7 @@ class DartRuntimeServiceOptions {
enableLogging: enableLogging ?? this.enableLogging,
port: port ?? this.port,
disableAuthCodes: disableAuthCodes ?? this.disableAuthCodes,
disableOriginCheck: disableOriginCheck ?? this.disableOriginCheck,
sseHandlerPath: sseHandlerPath ?? this.sseHandlerPath,
autoStart: autoStart ?? this.autoStart,
);
+316
View File
@@ -0,0 +1,316 @@
// 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 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
import 'package:logging/logging.dart';
import 'package:shelf/shelf.dart';
import 'package:vm_service/vm_service.dart' hide Response;
import 'dart_runtime_service.dart';
import 'dart_runtime_service_rpcs.dart';
import 'rpc_exceptions.dart';
/// A [DevelopmentFileSystem] rooted at [rootUri], providing restricted file
/// system access to clients.
abstract base class DevelopmentFileSystem {
DevelopmentFileSystem({required this.name, required this.rootUri});
final String name;
final Uri rootUri;
static Never throwInvalidUriParameter({
required String method,
required Object? uri,
}) => RpcException.invalidParams.throwExceptionWithDetails(
details: "$method: invalid 'uri' parameter: $uri",
);
static Never throwMissingUriParameter({required String method}) =>
RpcException.invalidParams.throwExceptionWithDetails(
details: "$method: expects the 'uri' parameter",
);
/// Reads the contents of the file at [uri].
///
/// If the file does not exist, a [RpcException.fileDoesNotExist] exception
/// is thrown.
Future<RpcResponse> readFile({required String uri});
/// Writes [bytes] to [uri].
Future<void> writeFile({required String uri, required List<int> bytes});
/// Writes a stream of [bytes] to [uri].
Future<void> writeStreamFile({
required String uri,
required Stream<List<int>> bytes,
});
/// Lists all files contained in the [DevelopmentFileSystem].
///
/// Each file is reported with its size in bytes and last modified timestamp
/// in milliseconds since epoch.
Future<RpcResponse> listFiles();
/// Resolves the [uri] against the [rootUri] of the [DevelopmentFileSystem].
///
/// [uri] must be a valid file URI with an optional leading `/`. If the
/// resolved URI is not within the file systems [rootUri], an
/// [RpcException.invalidParams] exception is thrown.
Uri resolve({required String method, required String uri}) {
// The leading '/' is optional but must be removed before resolving the
// URI, otherwise it will be treated as the file system root.
if (uri.startsWith('/')) {
uri = uri.substring(1);
}
final parsedUri = Uri.tryParse(uri);
if (parsedUri == null) {
throwInvalidUriParameter(method: method, uri: uri);
}
try {
// Make sure that this pathUri can be converted to a file path.
parsedUri.toFilePath();
// ignore: avoid_catching_errors
} on UnsupportedError {
throwInvalidUriParameter(method: method, uri: uri);
}
final resolvedUri = rootUri.resolveUri(parsedUri);
if (!resolvedUri.toString().startsWith(rootUri.toString())) {
// Resolved uri must be within the filesystem's base uri.
throwInvalidUriParameter(method: method, uri: uri);
}
return resolvedUri;
}
Map<String, String> toJson() => {
'type': 'FileSystem',
'name': name,
'uri': rootUri.toString(),
};
}
/// A collection of [DevelopmentFileSystem]s.
abstract base class DevelopmentFileSystemCollection {
List<String> get fsNames;
/// Destroys all [DevelopmentFileSystem]s in the collection.
Future<void> cleanup();
/// Creates a new [DevelopmentFileSystem] named [name].
///
/// Throws a [RpcException.fileSystemAlreadyExists] if the file system has
/// already been created.
Future<DevelopmentFileSystem> createFileSystem({required String name});
/// Destroys the [DevelopmentFileSystem] with name [name].
///
/// Throws a [RpcException.fileSystemDoesNotExist] if the file system does
/// not exist.
Future<void> deleteFileSystem({required String name});
/// Retrieves an existing [DevelopmentFileSystem] based on a JSON-RPC
/// request.
///
/// Throws a [RpcException.fileSystemDoesNotExist] if the file system does
/// not exist.
DevelopmentFileSystem getFileSystem({required String name});
}
/// A development file system used by service clients to upload compilation
/// artifacts and assets for use by the runtime.
class DevFS<DevFSBackend extends DevelopmentFileSystemCollection> {
DevFS({required this._fileSystems});
final DevFSBackend _fileSystems;
final _logger = Logger('$DevFS');
static const _kFsName = 'fsName';
static const _kUri = 'uri';
static const _kFiles = 'files';
static const _kFileContents = 'fileContents';
late final rpcs = UnmodifiableListView<ServiceRpcHandler>([
('_listDevFS', listDevFS),
('_createDevFS', createDevFS),
('_deleteDevFS', deleteDevFS),
('_readDevFSFile', readDevFSFile),
('_writeDevFSFile', writeDevFSFile),
('_writeDevFSFiles', writeDevFSFiles),
('_listDevFSFiles', listDevFSFiles),
]);
// Destroy the development file systems.
Future<void> cleanup() => _fileSystems.cleanup();
/// Responsible for processing file system writes initiated via an HTTP PUT
/// request.
///
/// In order to write a file, the HTTP PUT request must include the following
/// query parameters:
/// - `dev_fs_name`: the name of the [DevelopmentFileSystem] to write to.
/// - `dev_fs_uri_b64`: the base-64 encoded URI for the file to be written.
///
/// The request body will be treated as the contents of the file and written
/// to the provided URI rooted in the [DevelopmentFileSystem].
Future<Response?> handlePutStreamRequest(Request request) async {
if (request.method != 'PUT') {
return null;
}
_logger.info('Handling DevFS PUT request: ${request.headers}');
String? fsUri;
const kDevFsName = 'dev_fs_name';
const kDevFsUriBase64 = 'dev_fs_uri_b64';
// Extract the fs name and fs path from the request headers.
final fsName = request.headers[kDevFsName];
if (fsName == null) {
_logger.info('Invalid $kDevFsName. Returning.');
// TODO(bkonyi): this is wrong
return Response.internalServerError(body: 'Invalid $kDevFsName.');
}
if (request.headers[kDevFsUriBase64] case final String base64Uri) {
fsUri = utf8.decode(base64.decode(base64Uri));
}
if (fsUri == null) {
DevelopmentFileSystem.throwMissingUriParameter(method: '_writeDevFSFile');
}
_logger.info('Invoking handlePutStream.');
final result = await _handlePutStream(
fsName: fsName,
uri: fsUri,
bytes: request.read().cast<List<int>>().transform(gzip.decoder),
);
_logger.info('handlePutStream response: $result');
return Response.ok(
json.encode({'result': result}),
headers: {
// We closed the connection for bad origins earlier.
'Access-Control-Allow-Origin': '*',
'content-type': ContentType.json.mimeType,
},
);
}
Future<RpcResponse> _handlePutStream({
required String fsName,
required String uri,
required Stream<List<int>> bytes,
}) async {
_logger.info('Handling PUT write to $uri in $fsName');
final fs = _fileSystems.getFileSystem(name: fsName);
await fs.writeStreamFile(uri: uri, bytes: bytes);
return Success().toJson();
}
/// Lists the names of all active [DevelopmentFileSystem]s.
RpcResponse listDevFS() =>
// TODO(bkonyi): create package:vm_service type if we make this public.
{'type': 'FileSystemList', 'fsNames': _fileSystems.fsNames};
/// Creates a new [DevelopmentFileSystem] with a given `fsName`.
///
/// If a [DevelopmentFileSystem] with `fsName` already exists, an error is
/// returned.
Future<RpcResponse> createDevFS(json_rpc.Parameters parameters) async {
final fs = await _fileSystems.createFileSystem(
name: parameters[_kFsName].asString,
);
return fs.toJson();
}
/// Deletes the [DevelopmentFileSystem] with name `fsName`.
///
/// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is
/// returned.
Future<RpcResponse> deleteDevFS(json_rpc.Parameters parameters) async {
await _fileSystems.deleteFileSystem(name: parameters[_kFsName].asString);
return Success().toJson();
}
/// Reads a file from `uri` within the [DevelopmentFileSystem] `fsName`.
///
/// If a [DevelopmentFileSystem] with `fsName` does not exist, or `uri` is
/// does not point to a valid file, an error is returned.
Future<RpcResponse> readDevFSFile(json_rpc.Parameters parameters) async {
final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString);
final uri = parameters[_kUri].asString;
return await fs.readFile(uri: uri);
}
/// Writes `fileContents` to `uri` within the [DevelopmentFileSystem]
/// `fsName`.
///
/// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is
/// returned.
Future<RpcResponse> writeDevFSFile(json_rpc.Parameters parameters) async {
final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString);
final path = parameters[_kUri].asString;
final fileContents = parameters[_kFileContents].asString;
final decodedFileContents = base64.decode(fileContents);
await fs.writeFile(uri: path, bytes: decodedFileContents);
return Success().toJson();
}
/// Writes multiple `files` within the [DevelopmentFileSystem] `fsName`.
///
/// Each entry in `files` is a list with two entries:
/// - The URI of the file to be written to.
/// - The contents of the file.
///
/// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is
/// returned.
Future<RpcResponse> writeDevFSFiles(json_rpc.Parameters parameters) async {
final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString);
final files = parameters[_kFiles].asList.cast<Object?>();
final processed = <(String, Uint8List)>[];
Never throwInvalidFiles({required int index, required Object? fileInfo}) =>
RpcException.invalidParams.throwExceptionWithDetails(
details:
"_writeDevFSFiles: invalid '$_kFiles' parameter at index $index: "
'$fileInfo',
);
for (var i = 0; i < files.length; i++) {
final fileInfo = files[i];
if (fileInfo case [final String uriString, final String contents]) {
try {
fs.resolve(method: '_writeDevFSFiles', uri: uriString);
processed.add((uriString, base64.decode(contents)));
} catch (_) {
throwInvalidFiles(index: i, fileInfo: fileInfo);
}
} else {
throwInvalidFiles(index: i, fileInfo: fileInfo);
}
}
final pendingWrites = <Future<void>>[];
for (final (path, decodedContents) in processed) {
pendingWrites.add(fs.writeFile(uri: path, bytes: decodedContents));
}
await Future.wait(pendingWrites);
return Success().toJson();
}
/// Lists the set of files contained within the [DevelopmentFileSystem],
/// `fsName`.
///
/// If a [DevelopmentFileSystem] with `fsName` does not exist, an error is
/// returned.
Future<RpcResponse> listDevFSFiles(json_rpc.Parameters parameters) async {
final fs = _fileSystems.getFileSystem(name: parameters[_kFsName].asString);
return await fs.listFiles();
}
}
@@ -2,13 +2,21 @@
// 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 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
import 'package:logging/logging.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:sse/server/sse_handler.dart';
import 'package:stream_channel/stream_channel.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'clients.dart';
import 'dart_runtime_service.dart';
import 'dart_runtime_service_backend.dart';
/// Creates [Middleware] responsible for logging the result of HTTP requests.
///
@@ -54,6 +62,104 @@ Middleware authCodeVerificationMiddleware({required String authCode}) =>
return innerHandler(request.change(path: clientProvidedCode));
};
Middleware originCheckMiddleware({required DartRuntimeService frontend}) =>
(Handler innerHandler) => (Request request) {
// First check the web-socket specific origin.
var origins = request.headers['Sec-WebSocket-Origin'];
// Fall back to the general Origin field.
origins ??= request.headers['Origin'];
if (origins == null) {
// No origin sent. This is a non-browser client or a same-origin
// request.
return innerHandler(request);
}
bool isAllowedOrigin(String origin) {
Uri uri;
try {
uri = Uri.parse(origin);
} catch (_) {
return false;
}
// Explicitly add localhost and 127.0.0.1 on any port (necessary for
// adb port forwarding).
if ((uri.host == 'localhost') ||
(uri.host == InternetAddress.loopbackIPv6.address) ||
(uri.host == InternetAddress.loopbackIPv4.address)) {
return true;
}
final serverUri = frontend.uri;
if (uri.port == serverUri.port && uri.host == serverUri.host) {
return true;
}
return false;
}
for (final origin in origins.split(',')) {
if (isAllowedOrigin(origin)) {
return innerHandler(request);
}
}
return Response.forbidden('forbidden origin');
};
/// Creates a [Handler] responsible for processing HTTP requests.
///
/// If [frontend] has a [DartRuntimeServiceBackend] with a
/// [DartRuntimeServiceBackend.httpHandler] override, the backend's handler
/// will be invoked first. Otherwise, the HTTP request is treated as a JSON-RPC
/// invocation.
Handler httpRequestHandler({required DartRuntimeService frontend}) =>
(Request request) async {
final logger = Logger('HttpRequestHandler');
final method = request.url.pathSegments.firstOrNull ?? '';
final params = request.url.queryParameters;
logger.info('(${request.method}) ${request.url}');
try {
final backendResult = await frontend.backend.httpHandler(request);
if (backendResult != null) {
logger.info(
'Returning backend provided result: ${backendResult.statusCode}',
);
return backendResult;
}
final httpClient = StreamChannelController<String>(sync: true);
try {
frontend.addArtificialClient(
connection: httpClient.foreign,
name: 'HTTP request',
);
final jsonRpcClient = json_rpc.Client(httpClient.local);
unawaited(jsonRpcClient.listen());
final result = await jsonRpcClient.sendRequest(method, params);
logger.info('HTTP result: $result');
return Response.ok(
json.encode({'result': result}),
headers: {
// We closed the connection for bad origins earlier.
'Access-Control-Allow-Origin': '*',
'content-type': ContentType.json.mimeType,
},
);
} finally {
await Future.wait([
httpClient.foreign.sink.close(),
httpClient.local.sink.close(),
]);
}
} on json_rpc.RpcException catch (e) {
return Response.ok(json.encode(e.serialize(method)));
} catch (e) {
return Response.badRequest(body: e.toString());
}
};
/// Creates a [Handler] for incoming web socket connections.
Handler webSocketClientHandler({required ClientManager clientManager}) =>
webSocketHandler((WebSocketChannel ws, _) {
@@ -8,6 +8,7 @@ import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
enum RpcException {
// These error codes must be kept in sync with those in vm/json_stream.h and
// vmservice.dart.
invalidParams(code: INVALID_PARAMS, message: 'Invalid parameter.'),
serverError(code: SERVER_ERROR, message: 'Server error.'),
methodNotFound(code: METHOD_NOT_FOUND, message: 'Method not found.'),
internalError(code: INTERNAL_ERROR, message: 'Internal error.'),
@@ -20,13 +21,21 @@ enum RpcException {
expressionCompilationError(
code: 113,
message: 'Expression compilation error.',
);
),
fileSystemAlreadyExists(code: 1001, message: 'File system already exists.'),
fileSystemDoesNotExist(code: 1002, message: 'File system does not exist.'),
fileDoesNotExist(code: 1003, message: 'File does not exist.');
const RpcException({required this.code, required this.message});
/// Throws a [json_rpc.RpcException] with [code] and [message].
Never throwException({Object? data}) => throw toException(data: data);
/// Throws a [json_rpc.RpcException] with [code] and [message], with [details]
/// included in the exception's `data` field.
Never throwExceptionWithDetails({required String details}) =>
throw toException(data: <String, String>{'details': details});
/// Builds a [json_rpc.RpcException] with [code] and [message] without
/// throwing.
json_rpc.RpcException toException({Object? data}) =>
@@ -121,6 +121,7 @@ Future<void> main([List<String> args = const []]) async {
enableLogging: true,
port: _port,
disableAuthCodes: _authCodesDisabled,
disableOriginCheck: _originCheckDisabled,
autoStart: _autoStart,
),
backendBuilder: (frontend) => DartRuntimeServiceVMBackend(
@@ -16,6 +16,7 @@ import 'package:stream_channel/stream_channel.dart';
import 'src/dart_runtime_service_vm_rpcs.dart';
import 'src/native_bindings.dart';
import 'src/vm_dev_fs.dart';
import 'src/vm_expression_evaluator.dart';
import 'src/vm_isolate_manager.dart';
@@ -68,6 +69,7 @@ class DartRuntimeServiceVMBackend
final _nativeBindings = NativeBindings();
final _logger = Logger('$DartRuntimeServiceVMBackend');
final _devFs = VMDevelopmentFileSystemCollection.createDevFS();
@override
final VmIsolateManager isolateManager;
@@ -79,7 +81,7 @@ class DartRuntimeServiceVMBackend
@override
UnmodifiableListView<ServiceRpcHandler> get rpcs =>
UnmodifiableListView(_vmServiceRpcs.rpcs);
UnmodifiableListView([..._vmServiceRpcs.rpcs, ..._devFs.rpcs]);
@override
UnmodifiableListView<RpcHandlerWithParameters>
@@ -89,6 +91,9 @@ class DartRuntimeServiceVMBackend
sendToRuntime,
]);
@override
OptionalHandler get httpHandler => _devFs.handlePutStreamRequest;
@override
Future<void> initialize() async {
_logger.info('Initializing...');
@@ -116,6 +121,7 @@ class DartRuntimeServiceVMBackend
await Future.wait([
_sigquitSubscription?.cancel() ?? Future<void>.value(),
_nativeRpcClientStreamChannelController.local.sink.close(),
_devFs.cleanup(),
]);
isolateControlPort.close();
_nativeBindings.onExit();
@@ -15,7 +15,6 @@ import 'native_bindings.dart';
final class DartRuntimeServiceVmRpcs {
final _logger = Logger('$DartRuntimeServiceVmRpcs');
final _nativeBindings = NativeBindings();
late final rpcs = UnmodifiableListView<ServiceRpcHandler>([
('getSupportedProtocols', getSupportedProtocols),
]);
@@ -0,0 +1,246 @@
// 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 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dart_runtime_service/dart_runtime_service.dart';
import 'package:file/local.dart';
/// An outstanding write request managed by [_WriteLimiter].
class _PendingWrite {
_PendingWrite({
required this._localFs,
required this.uri,
required this.bytes,
});
final completer = Completer<void>();
final LocalFileSystem _localFs;
final Uri uri;
final Stream<List<int>> bytes;
Future<void> write() async {
final file = _localFs.file(uri);
final parentDir = file.parent;
await parentDir.create(recursive: true);
if (await file.exists()) {
await file.delete();
}
final sink = file.openWrite();
await sink.addStream(bytes);
await sink.close();
completer.complete();
_WriteLimiter._writeCompleted();
}
}
/// A utility class to schedule and limit the number of concurrent file system
/// writes as non-rooted Android devices have a very low limit for the number
/// of open files.
abstract class _WriteLimiter {
static final pendingWrites = <_PendingWrite>[];
// Artificially cap ourselves to 16.
static const _kMaxOpenWrites = 16;
static int _openWrites = 0;
static Future<void> scheduleWrite({
required LocalFileSystem localFs,
required Uri uri,
required List<int> bytes,
}) => scheduleWriteStream(
localFs: localFs,
uri: uri,
bytes: Stream.fromIterable([bytes]),
);
static Future<void> scheduleWriteStream({
required LocalFileSystem localFs,
required Uri uri,
required Stream<List<int>> bytes,
}) {
// Create a new pending write.
final pw = _PendingWrite(localFs: localFs, uri: uri, bytes: bytes);
pendingWrites.add(pw);
_maybeWriteFiles();
return pw.completer.future;
}
static void _maybeWriteFiles() {
while (_openWrites < _kMaxOpenWrites) {
if (pendingWrites.isEmpty) {
break;
}
final pw = pendingWrites.removeLast();
pw.write();
_openWrites++;
}
}
static void _writeCompleted() {
_openWrites--;
assert(_openWrites >= 0);
_maybeWriteFiles();
}
}
/// A [DevelopmentFileSystem] rooted at [rootUri], providing restricted file
/// system access to clients.
final class VMDevelopmentFileSystem extends DevelopmentFileSystem {
VMDevelopmentFileSystem({
required this._localFs,
required super.name,
required super.rootUri,
});
final LocalFileSystem _localFs;
/// Reads the contents of the file at [uri].
///
/// If the file does not exist, a [RpcException.fileDoesNotExist] exception
/// is thrown.
@override
Future<RpcResponse> readFile({required String uri}) async {
try {
final bytes = await _localFs
.file(resolve(method: '_readDevFSFile', uri: uri))
.readAsBytes();
// TODO(bkonyi): create package:vm_service type if we make this public.
return {'type': 'FSFile', 'fileContents': base64.encode(bytes)};
} on PathNotFoundException catch (e) {
RpcException.fileDoesNotExist.throwExceptionWithDetails(
details: '_readDevFSFile: $e',
);
}
}
/// Writes [bytes] to [uri].
@override
Future<void> writeFile({
required String uri,
required List<int> bytes,
}) async {
await _WriteLimiter.scheduleWrite(
localFs: _localFs,
uri: resolve(method: '_writeDevFSFile', uri: uri),
bytes: bytes,
);
}
/// Writes a stream of [bytes] to [uri].
@override
Future<void> writeStreamFile({
required String uri,
required Stream<List<int>> bytes,
}) async {
await _WriteLimiter.scheduleWriteStream(
localFs: _localFs,
uri: resolve(method: '_writeDevFSFile', uri: uri),
bytes: bytes,
);
}
/// Lists all files contained in the [DevelopmentFileSystem].
///
/// Each file is reported with its size in bytes and last modified timestamp
/// in milliseconds since epoch.
@override
Future<RpcResponse> listFiles() async {
final dir = _localFs.directory(rootUri);
final dirPathStr = dir.path;
final stream = dir.list(recursive: true);
final files = <Map<String, Object?>>[];
await for (final fileEntity in stream) {
final filePath = Uri.file(fileEntity.path).path;
final stat = await fileEntity.stat();
if (stat.type == FileSystemEntityType.file &&
filePath.startsWith(dirPathStr)) {
files.add(<String, Object?>{
// Remove any url-encoding in the filenames.
'name': Uri.decodeFull('/${filePath.substring(dirPathStr.length)}'),
'size': stat.size,
'modified': stat.modified.millisecondsSinceEpoch,
});
}
}
// TODO(bkonyi): create package:vm_service type if we make this public.
return <String, Object?>{'type': 'FSFileList', 'files': files};
}
}
/// A collection of [DevelopmentFileSystem]s.
final class VMDevelopmentFileSystemCollection
extends DevelopmentFileSystemCollection {
/// Creates a [DevFS] instance with a [VMDevelopmentFileSystemCollection]
/// backend.
static DevFS<VMDevelopmentFileSystemCollection> createDevFS() =>
DevFS(fileSystems: VMDevelopmentFileSystemCollection());
final _fsMap = <String, VMDevelopmentFileSystem>{};
final _localFs = const LocalFileSystem();
@override
List<String> get fsNames => _fsMap.keys.toList();
/// Destroys all [DevelopmentFileSystem]s in the collection.
@override
Future<void> cleanup() async {
await Future.wait(<Future<void>>[
for (final fs in _fsMap.values)
_localFs.directory(fs.rootUri).delete(recursive: true),
]);
_fsMap.clear();
}
/// Creates a new [DevelopmentFileSystem] named [name].
///
/// Throws a [RpcException.fileSystemAlreadyExists] if the file system has
/// already been created.
@override
Future<DevelopmentFileSystem> createFileSystem({required String name}) async {
if (_fsMap.containsKey(name)) {
RpcException.fileSystemAlreadyExists.throwExceptionWithDetails(
details: "_createDevFS: file system '$name' already exists",
);
}
final temp = await _localFs.systemTempDirectory.createTemp(name);
final uri = (await temp.childDirectory(name).create()).uri;
return _fsMap[name] = VMDevelopmentFileSystem(
localFs: _localFs,
name: name,
rootUri: uri,
);
}
/// Destroys the [DevelopmentFileSystem] with name [name].
///
/// Throws a [RpcException.fileSystemDoesNotExist] if the file system does
/// not exist.
@override
Future<void> deleteFileSystem({required String name}) async {
final fs = _fsMap.remove(name);
if (fs == null) {
RpcException.fileSystemDoesNotExist.throwExceptionWithDetails(
details: "_deleteDevFS: file system '$name' does not exist",
);
}
await _localFs.directory(fs.rootUri).delete(recursive: true);
}
/// Retrieves an existing [DevelopmentFileSystem] based on a JSON-RPC
/// request.
///
/// Throws a [RpcException.fileSystemDoesNotExist] if the file system does
/// not exist.
@override
DevelopmentFileSystem getFileSystem({required String name}) {
final fs = _fsMap[name];
if (fs == null) {
RpcException.fileSystemDoesNotExist.throwException();
}
return fs;
}
}
+1
View File
@@ -10,6 +10,7 @@ resolution: workspace
# Use 'any' constraints here; we get our versions from the DEPS file.
dependencies:
dart_runtime_service: any
file: any
json_rpc_2: any
logging: any
stream_channel: any
@@ -25,12 +25,11 @@ Future<String> readResponse(HttpClientResponse response) {
}
final tests = <VMTest>[
// Write a file with a ? in the filename.
(VmService service) async {
const fsId = 'test';
const filePath = '/foo/b?ar.dart';
const fileUri = 'foo/bar.dart';
const fileContents = [0, 1, 2, 3, 4, 5, 6, 255];
final filePathBase64 = base64Encode(utf8.encode(filePath));
final fileUriBase64 = base64Encode(utf8.encode(fileUri));
final fileContentsBase64 = base64Encode(fileContents);
Future<Map<String, dynamic>> postToDevFS({
@@ -41,7 +40,7 @@ final tests = <VMTest>[
final request = await client.putUrl(Uri.parse(serviceHttpAddress));
request.headers.add('dev_fs_name', fsId);
if (!omitDevFsPath) {
request.headers.add('dev_fs_path_b64', filePathBase64);
request.headers.add('dev_fs_uri_b64', fileUriBase64);
}
request.add(gzip.encode(content));
final response = await request.close();
@@ -81,7 +80,12 @@ final tests = <VMTest>[
}
}
}) {
expect(details.contains("expects the 'path' parameter"), true);
expect(
// TODO(bkonyi): remove the 'path' case once we move to the new VM
// service implementation.
details.contains(RegExp("expects the '(path|uri)' parameter")),
true,
);
} else {
invalidResponse(result);
}
@@ -100,7 +104,7 @@ final tests = <VMTest>[
'_readDevFSFile',
args: {
'fsName': fsId,
'path': filePath,
'uri': fileUri,
},
);
if (result case {'type': 'FSFile', 'fileContents': final String contents}) {
@@ -117,7 +121,9 @@ final tests = <VMTest>[
'fsName': fsId,
},
);
if (result case {'type': 'FSFileList', 'files': [{'name': filePath}]}) {
if (result
case {'type': 'FSFileList', 'files': [{'name': final String uri}]}
when uri.endsWith(fileUri)) {
// Expected
} else {
invalidResponse(result);
@@ -1,142 +0,0 @@
// Copyright (c) 2023, 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 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:test/test.dart';
import 'package:vm_service/vm_service.dart';
import '../common/test_helper.dart';
import 'private_rpc_common.dart';
Future<String> readResponse(HttpClientResponse response) {
final completer = Completer<String>();
final contents = StringBuffer();
response.transform(utf8.decoder).listen(
(String data) {
contents.write(data);
},
onDone: () => completer.complete(contents.toString()),
);
return completer.future;
}
final tests = <VMTest>[
// Write a file with the \r character in the filename.
(VmService service) async {
const fsId = 'test';
const filePath = '/foo/b\rar.dart';
const fileContents = [0, 1, 2, 3, 4, 5, 6, 255];
final filePathBase64 = base64Encode(utf8.encode(filePath));
final fileContentsBase64 = base64Encode(fileContents);
Future<Map<String, dynamic>> postToDevFS({
required List<int> content,
bool omitDevFsPath = false,
}) async {
final client = HttpClient();
final request = await client.putUrl(Uri.parse(serviceHttpAddress));
request.headers.add('dev_fs_name', fsId);
if (!omitDevFsPath) {
request.headers.add('dev_fs_path_b64', filePathBase64);
}
request.add(gzip.encode(content));
final response = await request.close();
final responseBody = await readResponse(response);
client.close();
return jsonDecode(responseBody);
}
// Create DevFS.
Map<String, dynamic> result = await callMethod(
service,
'_createDevFS',
args: {'fsName': fsId},
);
if (result case {'type': 'FileSystem', 'name': fsId, 'uri': String _}) {
// Expected
} else {
invalidResponse(result);
}
// Write the file by issuing an HTTP PUT.
result = await postToDevFS(content: [9]);
if (result case {'result': final Map<String, dynamic> innerResult}) {
expectSuccess(innerResult);
} else {
invalidResponse(result);
}
// Trigger an error by issuing an HTTP PUT.
result = await postToDevFS(content: fileContents, omitDevFsPath: true);
if (result
case {
'error': {
'data': {
'details': final String details,
}
}
}) {
expect(details.contains("expects the 'path' parameter"), true);
} else {
invalidResponse(result);
}
// Write the file again but this time with the true file contents.
result = await postToDevFS(content: fileContents);
if (result case {'result': final Map<String, dynamic> innerResult}) {
expectSuccess(innerResult);
} else {
invalidResponse(result);
}
// Read the file back.
result = await callMethod(
service,
'_readDevFSFile',
args: {
'fsName': fsId,
'path': filePath,
},
);
if (result case {'type': 'FSFile', 'fileContents': final String contents}) {
expect(contents, fileContentsBase64);
} else {
invalidResponse(result);
}
// List all the files in the file system.
result = await callMethod(
service,
'_listDevFSFiles',
args: {
'fsName': fsId,
},
);
if (result case {'type': 'FSFileList', 'files': [{'name': filePath}]}) {
// Expected
} else {
invalidResponse(result);
}
// Delete DevFS.
result = await callMethod(
service,
'_deleteDevFS',
args: {
'fsName': fsId,
},
);
expectSuccess(result);
},
];
void main(args) => runVMTests(
args,
tests,
'dev_fs_http_put_weird_char_test.dart',
);
@@ -72,7 +72,7 @@ final tests = <VMTest>[
},
(VmService service) async {
const fsId = 'banana';
const filePath = '/foo/bar.dat';
final fileUri = 'foo/bar.dat';
final fileContents = base64Encode(utf8.encode('fileContents'));
// Create DevFS.
@@ -93,7 +93,7 @@ final tests = <VMTest>[
'_readDevFSFile',
args: {
'fsName': fsId,
'path': filePath,
'uri': fileUri,
},
);
fail('Unreachable');
@@ -108,7 +108,7 @@ final tests = <VMTest>[
'_writeDevFSFile',
args: {
'fsName': fsId,
'path': filePath,
'uri': fileUri,
'fileContents': fileContents,
},
);
@@ -120,7 +120,7 @@ final tests = <VMTest>[
'_readDevFSFile',
args: {
'fsName': fsId,
'path': filePath,
'uri': fileUri,
},
);
if (result case {'type': 'FSFile', 'fileContents': final String contents}) {
@@ -129,19 +129,6 @@ final tests = <VMTest>[
invalidResponse(result);
}
// The leading '/' is optional.
result = await callMethod(
service,
'_readDevFSFile',
args: {
'fsName': fsId,
'path': filePath.substring(1),
},
);
if (result case {'type': 'FSFile', 'fileContents': final String contents}) {
expect(contents, fileContents);
}
// Read a file outside of the fs.
try {
await callMethod(
@@ -149,13 +136,13 @@ final tests = <VMTest>[
'_readDevFSFile',
args: {
'fsName': fsId,
'path': '../foo',
'uri': '../foo',
},
);
fail('Unreachable');
} on RPCError catch (e) {
expect(e.code, RPCErrorKind.kInvalidParams.code);
expect(e.details, "_readDevFSFile: invalid 'path' parameter: ../foo");
expect(e.details, "_readDevFSFile: invalid 'uri' parameter: ../foo");
}
// Write a set of files.
@@ -165,8 +152,8 @@ final tests = <VMTest>[
args: {
'fsName': fsId,
'files': [
['/a', base64Encode(utf8.encode('a_contents'))],
['/b', base64Encode(utf8.encode('b_contents'))],
['a', base64Encode(utf8.encode('a_contents'))],
['b', base64Encode(utf8.encode('b_contents'))],
],
},
);
@@ -178,7 +165,7 @@ final tests = <VMTest>[
'_readDevFSFile',
args: {
'fsName': fsId,
'path': '/b',
'uri': 'b',
},
);
@@ -74,7 +74,13 @@ final tests = <VMTest>[
// Trigger an error by issuing an HTTP PUT.
result = await postToDevFS(content: fileContents, omitDevFsUri: true);
if (result case {'error': {'data': {'details': final String details}}}) {
expect(details.contains("expects the 'path' parameter"), true);
print(details);
expect(
// TODO(bkonyi): remove the 'path' case once we move to the new VM
// service implementation.
details.contains(RegExp("expects the '(uri|path)' parameter")),
true,
);
} else {
invalidResponse(result);
}
@@ -1,93 +0,0 @@
// Copyright (c) 2023, 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 'dart:convert';
import 'package:test/test.dart';
import 'package:vm_service/vm_service.dart';
import '../common/test_helper.dart';
import 'private_rpc_common.dart';
final tests = <VMTest>[
// Write a file with the ? character in the filename.
(VmService service) async {
const fsId = 'test';
const filePath = '/foo/bar?dat';
final fileContents = base64Encode(utf8.encode('fileContents'));
// Create DevFS.
Map<String, dynamic> result = await callMethod(
service,
'_createDevFS',
args: {'fsName': fsId},
);
if (result case {'type': 'FileSystem', 'name': fsId, 'uri': String _}) {
// Expected
} else {
invalidResponse(result);
}
// Write the file.
result = await callMethod(
service,
'_writeDevFSFile',
args: {
'fsName': fsId,
'path': filePath,
'fileContents': fileContents,
},
);
expectSuccess(result);
// Read the file back.
result = await callMethod(
service,
'_readDevFSFile',
args: {
'fsName': fsId,
'path': filePath,
},
);
if (result case {'type': 'FSFile', 'fileContents': final String contents}) {
expect(contents, fileContents);
} else {
invalidResponse(result);
}
// List all the files in the file system.
result = await callMethod(
service,
'_listDevFSFiles',
args: {
'fsName': fsId,
},
);
if (result
case {
'type': 'FSFileList',
'files': [{'name': '/foo/bar?dat'}],
}) {
// Expected
} else {
invalidResponse(result);
}
// Delete DevFS.
result = await callMethod(
service,
'_deleteDevFS',
args: {
'fsName': fsId,
},
);
expectSuccess(result);
},
];
void main(List<String> args) => runVMTests(
args,
tests,
'dev_fs_weird_char_test.dart',
);