Writing a custom Dart VM service extension (part 1)
Tuesday, 19 March 2024
In this post, I’ll take a closer look at Dart VM’s service extensions mechanism and explain what service extensions are and why they are useful in certain situations. I’ll also show how to implement one.
The Dart language
Dart is a flexible language. It not only supports many target platforms and CPU architectures, but it can also be executed in a few different ways.
You might have heard the name “Dart Virtual Machine” tossed around. This name is a leftover from the times when Dart code could only be JIT compiled, which is no longer true – we’ve had AOT compilation for many years now. Currently, Dart VM is a virtual machine in a sense that it provides runtime environment for a high-level programming language1. Dart code can be JIT compiled or AOT compiled. I think that a useful - but likely not entirely true2 – simplification is that in the former case, the Dart VM behaves more like a JVM. In the latter, it’s similar to Go’s runtime, where both runtime, stdlib, and your code are statically compiled into the same binary.
What are service extensions?
Dart VM hosts a VM service, which is essentially a WebSockets server that you can communicate with using the JSON-RPC-based Dart VM Service Protocol. Through a VM service, you can interact with service extensions.
Service extensions is a mechanism that enables developers to add custom functionality to that server (think “custom endpoints”) without using any third-party packages.
Let’s imagine a running Dart VM with 3 isolates and some service extensions:
There’s a couple interesting points to take a note of:
There’s a single VM service
Service extensions are bound to the isolate they were registered in
The same extension can be registered in many isolates
So it follows that the client must always pass ID of the isolate when calling a service extension.
Why service extensions are useful?
Service extensions provide official hook points to a running Dart VM. Thanks to them, we’re able to query and modify its state, and extend it with new functionality.
You most likely will never find a use case for a Dart VM service extension when developing yet another app. They come useful in the more “frameworky” projects, often developer tooling-related.
Actually, many of the universally praised development-time
features that Flutter is known for – like Hot
Reload and Hot
Restart – are implemented as Dart VM Service extensions. Flutter
Engine also has a few service extensions. If you’re curious
and would like to dive deeper in its internals, vmservice.dart
file in the flutter_tools
package is a good starting point.
Service extensions are also essential to Flutter’s new DevTools Extensions system.
Dart, apart from service extensions defined in Dart VM Service Protocol, also has Dart Development Service Protocol and Dart VM Service Protocol Extension.
I first learned about and implemented service extensions when I was working on a new feature for a custom test framework for Flutter. That feature has been ditched for a while now, but the service extensions mechanism seemed pretty interesting to me, and not well-known, so I decided to share my knowledge.
New service extension in a Dart program
Let’s implement our first simple service extension.
To do so, we need a program that has some internal state. Then, we’ll expose that state through a service extension, and finally build another program that will call our service extension and query that internal state.
All code is available in the GitHub repo in the
dart_sample
directory.
What we’ll build
I’ll start with a very simple printer
program. All
it does is incrementing a global count
variable every
second and printing it to standard output, infinitely.
var count = 0;
void main() {
async {
() while (true) {
await Future.delayed(Duration(seconds: 1));
++;
count'Count: $count');
print(}
}();
}
Here’s its output after 3 seconds:
.dart
$ dart run printer: 0
Count: 1
Count: 2 Count
Now imagine that you want to write another Dart program, let’s
call it spy
, that would be able to ask
printer
about its state. There are lots of ways to it
– HTTP server, WebSockets, Unix Domain Sockets, and many more, but
since this is a post about service extensions, I’ll use them
(service extension actually work over WebSockets). Here’s how I
imagine spy
to work when run:
$ dart run spy.dart
The printer program has counted to 21
Looks pretty basic, but it’s enough for now. Everything needed
to implement a new service extension is in the built-in dart:developer
package.
Implementing
printer
The first thing to do is pick a name for our new service
extension. I went with ext.printer.getCount
– custom
service extensions must start with ext
:
const String extensionName = 'ext.printer.getCount';
Then I register the _getCountHandler
function to
run when the extension is called. _getCountHandler
must conform to the ServiceExtensionHandler
typedef. In our case, it’s going to simply return a small JSON
containing count
:
.registerExtension(extensionName, _getCountHandler);
developer
Future<developer.ServiceExtensionResponse> _getCountHandler(
String method,
Map<String, String> parameters,
async {
) final Map<String, dynamic> result = {
'status': 'printing',
'count': count,
};
return developer.ServiceExtensionResponse.result(jsonEncode(result));
}
Remember that service extensions are isolate
specific. When you call developer.registerExtension(method, handler)
it registers the extension in the isolate where it was called. The
clients wishing to interact with our extension will be required to
know the ID of the that isolate. Let’s print the ID of the current
(in our case there is only one - the main
isolate)
isolate so we can copy it later:
final String isolateId = developer.Service.getIsolateID(Isolate.current)!;
'Registered service extension $extensionName in $isolateId'); print(
And that’s it for the printer
program. Here
is its full code. Now, let’s make sure it works and that our
extension is registered. To do it, run priner
first
with Dart VM service enabled (it’s disabled by default) by passing
the --enable-vm-service
flag to
dart run
:
$ dart run --enable-vm-service bin/printer.dart
The Dart VM service is listening on http://127.0.0.1:8181/FmMJoBzneFU=/
The Dart DevTools debugger and profiler is available at: http://127.0.0.1:8181/FmMJoBzneFU=/devtools?uri=ws://127.0.0.1:8181/FmMJoBzneFU=/ws
Count: 0
Registered service extension ext.printer.getCount in isolates/718812529134167
Count: 1
Count: 2
We will need two things from this output: the address of the VM service and the ID of the isolate where the service extension is registered:
- VM service address is
ws://127.0.0.1:8181/FmMJoBzneFU=/
(replacehttp
withws
) - isolate ID is
isolates/718812529134167
(there’s only one isolate in our program)
Implementing spy
– first
version
Now we’re getting to the cool stuff. The spy
program will connect to the Dart VM Service that is running the
printer
program, and call our own service extension
that will return the current count
value.
A quick reminder that the whole code is available on GitHub. The
spy
program is available in three versions -spy_basic
,spy
, andspy_best
. Right now I’m explaining implementation ofspy_basic
.
As I already said, we need two inputs: VM service address (needed to establish a WebSockets connection) and isolate where the service extension is registered.
import 'dart:io' as io;
Future<void> main(List<String> args) async {
final String webSocketUrl = args[0];
final String isolateId = args[1];
final io.WebSocket socket = await io.WebSocket.connect(webSocketUrl);
}
After creating a socket, let’s listen on it:
.listen(
socketdynamic data) {
(var encoder = JsonEncoder.withIndent(' ');
final response = encoder.convert(jsonDecode(data));
'Got response from ext.printer.getCount:\n$response');
print(},
);
Finally, let’s call the service extension. In
params
we pass the isolateId
that we got
as the second argument on the command line:
'Calling service extension ext.printer.getCount...');
print(.add(jsonEncode({
socket'jsonrpc': '2.0',
'id': 1,
'method': 'ext.printer.getCount',
'params': {'isolateId': isolateId},
}));
.close(); socket
This program will very likely finish before we get a response. Let’s put some duct tape on it:
// Simply, hacky way to keep running until the response is received.
await Future.delayed(const Duration(seconds: 1));
// Let's also close the socket.
.close(); socket
That should be it. Let’s run both programs now, starting with
printer
.
$ dart run --enable-vm-service bin/printer.dart
The Dart VM service is listening on http://127.0.0.1:8181/IEtIpIQOzi4=/
The Dart DevTools debugger and profiler is available at: http://127.0.0.1:8181/IEtIpIQOzi4=/devtools?uri=ws://127.0.0.1:8181/IEtIpIQOzi4=/ws
Registered service extension ext.printer.getCount in isolates/6010531716406367
Count: 0
Count: 1
Then, run spy
, passing it the values we got from
printer
’s initial output. Remember to replace
http
with ws
in the VM service
address.
dart run bin/spy.dart ws://127.0.0.1:8181/IEtIpIQOzi4=/ isolates/6010531716406367
Calling service extension ext.printer.getCount...
Got response from ext.printer.getCount: {status: printing, count: 2}
It works - we got the response! Here’s the full code of spy.
More safety thanks to the vm_service package
I said in the beginning that everything needed to implement a new service extension is built in – no external packages are needed. As you can see above, that’s 100% true, but there are a few problems:
- No type safety - it’s all strings flying back and forth
- Low discoverability - no documentation on hover
- Request-response - you need to assign IDs to requests yourself and then implement some loop to handle them
When your service extension grows in complexity, you’ll sooner or later end up writing your own little library around basic VM service features such as getting the VM, finding the ID of the main isolate, a simple event loop, etc. That’s where the vm_service package comes into play – it provides many basic building blocks that make writing a service extension easier.
Implementing spy
– second
version
Let’s rewrite spy
using the
vm_service
package. Beginning is mostly the same:
import 'package:vm_service/vm_service.dart' as vm_service;
Future<void> main(List<String> args) async {
final String webSocketUrl = args[0];
final String isolateId = args[1];
final io.WebSocket socket = await io.WebSocket.connect(webSocketUrl);
}
Now we need to create a stream controller and a completer. They
are required by VmService
, which we’ll create
shortly.
final StreamController<dynamic> controller = StreamController<dynamic>();
final Completer<void> streamClosedCompleter = Completer<void>();
.listen(
socketdynamic data) => controller.add(data),
(: () => streamClosedCompleter.complete(),
onDone );
Finally, create an instance of VmService
,
which provides a higher-level type-safe interface to a VM remote
service.
// VmService is a reference to the VM service that is (possibly) running in a
// different VM
final vm_service.VmService vmService = vm_service.VmService(
.stream,
controller.add,
socket: () => socket.close(),
disposeHandler: streamClosedCompleter.future,
streamClosed );
Finally, we can call the service extension using VmService.callServiceExtension(method, {isolateId, args})
:
final serviceExtensionName = 'ext.printer.getCount';
'Calling service extension $serviceExtensionName...');
print(final vm_service.Response response = await vmService.callServiceExtension(
,
serviceExtensionName: isolateId,
isolateId
);'Got response from $serviceExtensionName: ${response.json}');
print(.close(); socket
Full
code here. This version of our “spy” VM service client is
actually more lines of code – but it is one time boilerplate that
provides a good foundation if your service-extensions-based
project grows. Notice how we no longer care about low-level
details like calling toJson()
on a map, specifying
protocol, or assigning ids to messages.
One very cool improvement that can be made to this program is automatic retrieval of the main isolate ID. Since it’s very similar I won’t explain it in detail again, instead I’ll just link to its code.
Summary
That’s all for this post. It’s been in my “writing cabinet” since May 2023, and I’ve been procrastinating so much on it. That’s mainly because I also wanted to talk about service extensions in the context of Flutter, but it would make the post 50% longer. I still plan to do it – but in part 2. For now, it is what it is.
But before we split, let me whet your appetite for part 2.
New service extension in a Flutter app
The examples I showed were, let’s face it, toy ones. The real strength of service extensions can be seen when working on developer tools for Flutter.
The main difference between the previous pure-Dart examples and
Flutter app is that in case of a Flutter app (running in debug
mode), the VM service connection already exists – it’s
automatically estabilished by the flutter
tool when
you flutter run
(or flutter attach
).
We’ll take advantage of that and won’t create a new one3.
In case of a Flutter app, service extensions enable the following 2 scenarios:
- Service extension runs in the app, host machine connects to it. This enables the host to reach into the app and explore its internal state.
- Service extension runs on the host machine,
the app connects to it. This enables the app to reach out
to the grand outside world. This is useful if something can’t be
done from withing the device (e.g. executing some powerful
adb
commands), but is trivial to do from the host.
So yeah, in that future blog post I’ll show both. Done is better than perfect, as they say, ha!
The end
I hope you enjoyed it! See you soon in part 2.
2024-08-18 – frp (Fast Reverse Proxy)
instead of ngrok
2024-08-16 – Cirrus CI is the best CI system
out there
2024-08-14 – Going to Berlin for
Droidcon/Fluttercon
2024-06-25 – I was awarded Google Open Source Peer
Bonus
2024-06-04 – My journey to Google I/O
’24
2024-05-11 – GitHub Actions
beg for a supply chain attack
2024-03-19 – Writing a custom Dart
VM service extension (part 1)
2024-02-08 – On using smartphone for things
that make sense
2023-11-30 – Semantics in Flutter - under
the hood
2023-11-25 – Flutter Engine notes
2023-09-17 – Creating
and managing Android Virtual Devices using the terminal
2023-05-27 – Suckless
Android SDK setup
2023-05-26 – Let’s start over
2023-05-21 –
Short thought on “The Zen of
Unix”
2023-05-15 – Notes about “flutter
assemble”
2019-01-07 – Google Code-in
2018
Taken from https://mrale.ph/dartvm. It’s a great website.↩︎
If some experienced Dart hacker is reading this post, please do let me know how far from the truth I am.↩︎
Of course, you could create another VM service connection, but I don’t see a reason why you’d want to do that. If you have an idea, drop me a line!↩︎