Three weeks ago, I wrote about the stream_channel
package. Two weeks ago, I wrote about the json_rpc_2
package which is built on top of stream_channel
. This week I’ll complete the trifecta by writing about the vm_service_client
package, which uses json_rpc_2
in turn—and is a really cool package in its own right!
One of the lesser-known corners of the Dart VM is its service protocol, but it’s one of its most powerful components. It uses JSON-RPC 2.0 over WebSockets to allow clients to connect to the VM, inspect its internal state, set breakpoints, and all sorts of neat stuff. If you’ve ever used Observatory for debugging or profiling your Dart code, you’ve been using the service protocol under the hood: that’s how the Observatory web app talks to the VM’s internals.
Because the protocol is fully documented and based on a standard underlying protocol, it’s possible for anyone to use from their code. And the vm_service_client
package makes it downright easy: it provides a Dart-y object-oriented API for (more or less) everything in the protocol. And that turns out to be a lot of stuff: I count 108 classes in the API documentation, with more added over time as the protocol adds new features.
Because the client’s surface area is so broad, I’m not even going to attempt to cover all of it. I’ll discuss the most important classes, of course, but I also want to touch on the broader picture of how we took a language-independent RPC protocol and created a native-feeling API to use it.
Connecting the Client
Before we go deep into the API, though, let’s start at the beginning: actually establishing a connection with a running VM instance. The first step is to get the VM to actually run the service protocol at all. If you run dart --enable-vm-service
, it will listen for WebSocket connections on ws://localhost:8181/ws
(by default).1 You can also force a running Dart process to start the VM service by sending it a SIGQUIT
signal, as long as you’re not on Windows, but that’s a lot less reliable and customizable.
Once the service is running, you can connect a client to it using new VMServiceClient.connect()
. This takes the service protocol’s WebSocket URL as either a string or a Uri
—and, for convenience, it can also take Observatory’s HTTP URL, which it will use to figure out the corresponding WebSocket URL. If anything goes wrong, it’ll be reported through the done
future.
import "package:vm_service_client/vm_service_client.dart";
main(List<String> args) async {
var url = args.isEmpty ? "ws://localhost:8181/ws" : args.first;
var client = new VMServiceClient.connect(url);
// ...
}
References
Almost every piece of data the service protocol provides comes in two varieties: the reference and the full version. The reference contains a little bit of metadata, as well as enough information to make an RPC that will provide the full version, which contains all available information.
This split accomplishes two things. It makes the responses much more compact by avoiding unnecessary metadata. More importantly, though, it allows for circularity. A library can refer to the classes it contains, which can in turn refer back to the library that contains them.
In the client, all reference classes end in Ref
, whereas their full versions do not. So VMLibraryRef
is a reference to a VMLibrary
. The full versions extend their corresponding references, so you can pass a VMLibrary
to a method that expects a VMLibraryRef
. Every reference has a load()
method that returns its full version.
This is an important place where the client makes the API feel more native. Because the service protocol can’t really overload an RPC based on its argument type, it has to have different calls for resolving references to different types of objects. But as a Dart client, we can provide an extra degree of uniformity. This is a pattern that comes up several times throughout the client.
Runnable Isolates
Isolates are a special case: they have three states instead of just two. There’s VMIsolateRef
and VMIsolate
, but neither of these will provide all the isolate’s information. For that you need VMRunnableIsolate
.
The extra layer exists because the VM loads an isolate in stages. First it creates the isolate with simple metadata like its name and its start time. But it needs to do a bunch of additional work, loading libraries and classes and stuff like that, before it can actually run any code in the isolate. Only once all that work is done is a VMRunnableIsolate
available.
You can use the VMServiceClient.onIsolateRunnable
stream to get a notification when the isolate you care about is runnable, but if you already have a reference to an unrunnable version there’s an easier way. VMIsolateRef.loadRunnable()
returns the runnable version once it’s available, and does so in a way that’s guaranteed to be safe from race conditions.
Navigating the Service
Once you have a connection to the service, you need to be able to find the part you want to interact with. I don’t mean looking up its API docs, I mean actually getting to it from your VMServiceClient
object. For the most part, the VM service is organized hierarchically:
VMServiceClient.getVM()
gives you the client’s singleVM
.VM.isolates
gives you all the VM’sVMIsolateRef
s.VMRunnableIsolate.libraries
gives you all theVMLibraryRef
s loaded by the isolate.VMLibrary.classes
gives you all theVMClassRef
s defined in the library.VMClass.fields
gives you the class’s fields, andVMClass.functions
gives you its methods.
Note that many of these getters are maps. This comes entirely from the client: the service protocol always sends collections down as lists for compactness. But it’s useful for users to be able to look up libraries by their URIs or classes by their names, so the client converts the lists into the data structure that best fits with how we expect users to interact with the data.
Let’s take a look at how you’d add a breakpoint at the beginning of a call to File.open()
from dart:io
:
var vm = await client.getVM();
var isolate = await vm.isolates.first.loadRunnable();
var library = await isolate.libraries[Uri.parse("dart:io")].load();
var file = await library.classes["File"].load();
file.functions["open"].addBreakpoint();
Instances
Code deals with data, so the VM service needs a way to represent data, and that way is the Instance class. Like most types, Instances have references and full values—these are represented in the client as VMInstanceRef
and VMInstance
.
On its own, VMInstance
only provides two pieces of information. Its klass
getter returns the VMClassRef
representing the instance’s class, and its fields
getter returns its fields and their values. In practice, though, many instances include more information—the service provides extra information for many core library types, which the client represents as classes like VMIntInstance
and VMListInstance
.
The client also provides VMInstanceRef.getValue()
, a convenience method for converting instances to local Dart objects. Every VMInstanceRef
subclass overrides this to recreate their particular type of object. It also takes an optional onUnknownValue()
callback to which plain instances—including those in data structures—are passed to be converted into local values based on the caller’s logic.
Evaluating Code
The VM service doesn’t just let you look at the VM’s state, it lets you run code on it as well! A few different classes have evaluate()
methods that take strings to evaluate and return VMInstanceRef
s. Where you evaluate the code determines what names are accessible.
VMLibraryRef.evaluate()
runs code in the context of a library. This lets the code access anything the library has imported, as well as any of its private names.VMClassRef.evaluate()
runs code in the context of a class. This is mostly the same as the library context, except that the code can refer to static class members without needing to prefix them with the class’s name.VMInstanceRef.evaluate()
runs code in the context of an instance. This means the code can refer to the instances fields and tothis
.VMFrameRef.evaluate()
can be used when an isolate is paused to run code in the same context as one of the current stack frames.
This is another example of the client using the same API to draw similarities between different parts of the underlying protocol. Because the client has the well-known unifying metaphors of objects and methods, it’s able to take disparate APIs and expose them in a consistent way that isn’t possible using raw RPCs.
Go Forth and Make Something Cool
In some ways, the VM service client is the most exciting package I’ve written about yet. It’s designed to make available a whole bunch of internal VM functionality, and the only limit on what can be done with that functionality is your imagination. So take this package and make something cool. Make a REPL or a visual object inspector. Heck, make an entirely new debugger!
Join me again in two weeks when I write about one of the oldest and most fundamental packages in the entire Dart ecosystem.
- Humans interacting directly with Observatory usually pass
--observe
flag instead of--enable-vm-service
. This will also enable the service, but it also turns on a handful of other options, the exact set of which is subject to change. It’s much safer to use--enable-vm-service
when writing code to interact with the VM. ↩