Remote server functionality in probe-rs
We have recently merged a series of exciting patches1 2 3 4 into probe-rs, that implement server-client functionality using websockets and postcard-rpc. In this post, I’ll try to explain the motivation behind the work, the the design, configuration and usage of this feature.
Why?
I’ve been recently given the opportunity to try teleprobe. Teleprobe is the software that powers the embassy HIL test rig, and it gives a very convenient way to run a cargo project on an MCU that is connected to a remote machine. I was pretty impressed with the experience it gave me, and since teleprobe builds on top of probe-rs, I thought why shouldn’t we integrate a similar functionality into probe-rs itself?
Besides the cool factor, remote access means I don’t have to carry my boards with me, I can share my hardware park with others, and my PCs are isolated from the boards. I can also just grab my laptop and move around to a more comfortable place in my apartment to do work, without fooling around with cables. In theory, I can also use it to connect to boards from inside WSL, without the overhead of USB/IP.
Did I mention the cool factor?
How?
The change affects the probe-rs CLI (probe-rs-tools). I tried my best to not introduce changes into the library itself, although I did end up redesigning how progress reporting works during device flashing.
From very early on, websockets as a transport was a natural choice. I wanted the server to implement a web server that served a status page, and if we already had a server in place, why would we use another transport for the communication itself?
In broad strokes, the architecture I wanted looks like this:
┌────────────────┐
Local operation ┌─►│Function handler│
│ └────────────────┘
┌────────┐ ┌────────────────┐ ┌──────────────┐ │
│probe-rs│ │ Client │ Channel │ Server │ │ ┌────────────────┐
│ CLI │◄───►│ implementation │◄──────────►│ and dispatch │◄┼─►│Function handler│
└────────┘ └────────────────┘ └──────────────┘ │ └────────────────┘
│
│ ┌────────────────┐
└─►│Function handler│
└────────────────┘
──────────────────────────────────────────────────────────────────────────────────
┌────────────────┐
Remote operation ┌─►│Function handler│
│ └────────────────┘
┌────────┐ ┌────────────────┐ ┌──────────────┐ │
│probe-rs│ │ Client │ Websocket │ Server │ │ ┌────────────────┐
│ CLI │◄───►│ implementation │◄──────────►│ and dispatch │◄┼─►│Function handler│
└────────┘ └────────────────┘ └──────────────┘ │ └────────────────┘
│
│ ┌────────────────┐
└─►│Function handler│
└────────────────┘
The idea has always been to keep duplication to a minimum. There is a single CLI codebase, there is
a single server/RPC codebase, only the transport is different between local and remote sessions.
This architecture has a nice benefit: we can write multiple frontends for the same server, which
means we can easily make cargo embed
remote-capable, too, or someone can write a probe-rs GUI
application, or more!
It took me three attempts to reach the current state of the implementation. The first attempt was a simple “let’s pipe stdin/stdout over websockets” implementation, which was simple enough, but very limited and not the greatest UX. For example, it wasn’t able to display progress bars. There was some push back against the idea, so after a few days of playing with it, I moved on to the second attempt.
For the second attempt, I’ve rolled my own, rather simplistic RPC implementation. At first, I used JSON to serialize data, my own simple dispatch method, and absolutely no compatibility checks between the server and the client. While it worked okay, I quickly ran into a few problems:
- I did not implement any sort of versioning into the API, so different probe-rs versions could silently miscommunicate. Not great.
- I had some very smelly hacks in the code, to get command cancellation working. The implementation
worked, but I expect nobody would have merged a PR that blocked tokio in a
Drop
implementation.
In general, this attempt showed me that an RPC implementation was viable, but I needed something more structured than my own terrible non-solution.
For the third attempt, I chose postcard-rpc. Postcard is a simple, embedded-focused serialization format that I’ve been meaning to try for a long time, and this was the perfect opportunity. Postcard RPC is, as the name suggests, built on top of Postcard and Postcard Schema, to provide the bits I needed: a client, dispatch, the possibility to implement my own transport, and best of all, a very friendly author!
After the second attempt, reworking the code on top of Postcard RPC actually took a bit of time. While most of the changes were mechanical at this point (not counting actually trying to come up with an okay-looking commit history), I had a few challenges:
- I needed to find a way to implement a transport layer that works both remotely and locally. I
ended up implementing
tokio
Channel
objects asWireTx
andWireRx
, and a bit of code around them to either connect the channels in process memory, or pipe messages through websockets. - I needed a way to make sure messages aren’t lost when the server streamed lots of messages in a topic. PRPC had a concept that allowed me to implement this, I merely had to undeprecate exclusive subscribers and implement a global timeout to throttle messages with.
- Lastly, I ran into a bug that only manifested on low performance machines, and resulted in my client freezing completely. Thanks James for fixing it quickly!
Besides Postcard RPC, probe-rs itself posed a few challenges for me:
- Some tasks, for example flashing firmware, take a long time. We receive progress updates through
callbacks, but then we have to somehow communicate them to the client and display them on a progress
bar, without locking everything up. I wrote a utility function that uses
tokio::task::spawn_blocking
to run the blocking code, and used channels to stream events out of the task. - These long-running tasks need to be cancellable. The user might press ctrl-c at any time, and we
shouldn’t keep the server busy running something when the client has gone away. I added a
Cancel
topic that the client can write, and the server manages a cancellation token that can gracefully, or sometimes more forcefully end a function. I probably didn’t get this right just yet, as the cancellation can take some time to actually become effective, and the scheme isn’t implemented for everything anyway, but future improvements are okay.
What’s left to do here?
The work is far from finished. Not every function has been ported: commands I don’t personally use
like profile
, benchmark
, trace
, as well as more complex ones like gdb
, debug
and
dap-server
need to be rewritten. cargo embed
and cargo flash
also can be updated to use the
RPC client. Currently they are included in the probe-rs-tools
binary to reduce code duplication,
but if we extract the RPC implementation, they can be split out again without losing any
functionality.
All right, long story, but we’ve arrived at the interesting part: how can we use this?
Installation
For now, the server/client functions are not enabled by default. This means you’ll have to install
probe-rs from source, with the remote
feature enabled. If you have all the prerequisites, and
you are compiling for your machine (as opposed to, for example, cross-compiling for a Raspberry Pi),
the command you would use is:
cargo install probe-rs-tools --git https://github.com/probe-rs/probe-rs --locked --features remote
You will need to do this installation for both your client machine, and your server. If your server is a Raspberry Pi or similar device that is not able to compile probe-rs itself, please take a look at the cross-compilation guide!
Configuration
Before starting the server, you will need to do configure it. To do so, create a .probe-rs.toml
(or YAML, or JSON, but I’ll be using TOML in this article) file in your home folder on the server.
Currently the only thing you can define is a list of users, but in the future we’ll have more
options. For each user you will need to assign a unique token. Clients will need to specify this
token to connect. Keep these tokens secret, and make sure the users do the same.
Your configuration may look like this:
[[server_users]]
name = "danielb"
token = "asbd"
[[server_users]]
name = "johnc"
token = "alskdhsaoighew"
On the client side, the configuration file is optional, but it can be used to make working with
remotes and multiple devices easier. Client configuration is done using preset
s, which are named
bundles of parameters. These parameters act exactly as arguments to the command-line program. For
example, the following configuration file defines two presets, both of them configure probe-rs to
work with different devices:
[presets]
esp32c2 = { probe = "0403:6010:FT929K8X" }
stm32f051r8-jlink = { probe = "1366:0101:000778807372", speed = 1000, chip = "stm32f051r8tx" }
You can select etween these presets by passing --preset <NAME>
to the CLI, like
probe-rs run --preset esp32c2
. This will then be equivalent to you running
probe-rs run --probe "0403:6010:FT929K8X"
.
Running commands remotely
On the server side, you don’t need a lot to do. Connect your probes, make sure the machine is
visible on your network, then run probe-rs serve
. This will load the configuration, then start
the server.
You will then be able to open http://<servers-hostname>:3000
in your browser for a simple page
that lists the connected probes. You can use this page to verify that the server is running and is
accessible as expected.
For remote access, services like ngrok
work well enough, although you should be aware that the
client does not do any TLS certificate verification currently.
Once you have a server configured and up and running, you’ll be able to use the server’s hostname
and one of the user tokens to connect to it. You can specify the remote host with --host
, and the
token with --token
. The host name will need the ws://
(websocket) or wss://
(websocket over
TLS) schema, and port 3000
.
For example, if your server’s host name is probe-rs-server
, you would write the following:
probe-rs run <path-to-your-elf> --chip stm32f051r8tx --host ws://probe-rs-server:3000 --token asbd
Alternatively, you can add the host and token to the configuration presets, and then you can just run:
probe-rs run <path-to-your-elf> --preset stm32f051r8tx-jlink
Limitations
--chip-description-path
is not supported remotely. We plan on enabling this later, but it needs
some amount of probe-rs library work.
Not every command is supported remotely. At the time of writing this post, the list of commands that are working remotely is:
list
read
write
reset
chip
info
download
attach
run
erase
verify
You will also not be able to see logs generated by the server, or generate error reports.
I hope this will turn out to be as useful for you as it is for me. If you have questions or suggestions, drop by in the #probe-rs matrix server, and let’s chat!