Establishing connections
- Picking an async runtime
- Connecting to a service
- Low-level method calls
- The two halves
- Server-side connections
The first thing you will have to do is to connect to a Varlink service. This is the entry point of the zlink API.
Picking an async runtime
zlink is async-first and runtime-agnostic at its core, with ready-made integration for the two popular runtimes, enabled through cargo features:
# tokio (the default):
zlink = "0.7"
# ...or smol:
zlink = { version = "0.7", default-features = false, features = ["smol", "service", "proxy", "tracing"] }
The features are additive — you can enable both at once. The bulk of the API is runtime-agnostic
and lives at the crate root; only the transport-specific parts (the unix and notified modules)
are per-runtime, under zlink::tokio and zlink::smol respectively. The examples in this book use
tokio, but they work identically with smol — just replace zlink::tokio with zlink::smol. If you
enable neither feature, the crate fails to compile with an explicit error, since the transport
implementations live in the runtime-integration crates.
Connecting to a service
As we saw in the previous chapter, there is no bus to connect to — you connect directly to the service you’re interested in, through the socket it listens on:
#[tokio::main]
async fn main() -> zlink::Result<()> {
let mut conn = zlink::tokio::unix::connect("/run/systemd/resolve/io.systemd.Resolve").await?;
// ... use the connection ...
Ok(())
}
That’s it. No handshake, no authentication round-trips, no name registration: after connect
returns, you can immediately send your first method call. The returned type is
zlink::tokio::unix::Connection, an alias for zlink::Connection<zlink::tokio::unix::Stream> —
the Connection type is generic over its transport (more on that in the
embedded chapter).
Note that connect() gives you the connection by value and all its methods take &mut self.
This is a deliberate — and central — design decision in zlink: a connection is exclusively owned by
whoever is using it, and is not meant to be shared. If two tasks need to talk to the same service,
each opens its own connection. Since Varlink connections are cheap, this costs you very little and
buys you a lot; the Design for efficiency chapter explains why this is one of the
main reasons zlink can outperform bus-based IPC.
Low-level method calls
For most use cases you’ll want the high-level proxy and service
macros, but let’s start at the bottom to understand what the machinery looks like. Connection
provides send_call, receive_reply and the combined call_method for the client side.
Method calls are represented by the Call<M> type, where M is any serializable type that
produces the method and parameters fields of the wire format. An
enum with serde’s tag/content attributes fits this shape perfectly, and the
serde_prefix_all crate saves us from repeating the interface name on every variant:
use serde::{Deserialize, Serialize};
use serde_prefix_all::prefix_all;
use zlink::{Call, reply};
// The methods of the `io.systemd.Resolve` interface (well, two of them).
#[prefix_all("io.systemd.Resolve.")]
#[derive(Debug, Serialize)]
#[serde(tag = "method", content = "parameters")]
enum ResolveMethods<'m> {
ResolveHostname { name: &'m str },
ResolveAddress { address: &'m [u8] },
}
// The relevant fields of a `ResolveHostname` reply.
#[derive(Debug, Deserialize)]
struct ResolveHostnameReply {
addresses: Vec<ResolvedAddress>,
}
#[derive(Debug, Deserialize)]
struct ResolvedAddress {
family: i32,
address: Vec<u8>,
}
// The errors of the interface (again, abbreviated).
#[derive(Debug, zlink::ReplyError)]
#[zlink(interface = "io.systemd.Resolve")]
enum ResolveError {
NoNameServers,
QueryTimedOut,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut conn = zlink::tokio::unix::connect("/run/systemd/resolve/io.systemd.Resolve").await?;
let call = Call::new(ResolveMethods::ResolveHostname { name: "systemd.io" });
let (reply, _fds): (reply::Result<ResolveHostnameReply, ResolveError>, _) =
conn.call_method(&call, vec![]).await?;
match reply {
Ok(reply) => println!("resolved: {:?}", reply.parameters()),
Err(e) => eprintln!("failed to resolve: {e:?}"),
}
Ok(())
}
A few things to note here:
call_method(and the underlyingreceive_reply) is generic over the reply and the error type. The result is a nestedResult: the outerzlink::Resultcovers connection-level problems (I/O errors, protocol violations, etc. — anything that isn’t an answer from the service) while the innerreply::Result<Params, Error>distinguishes a successful reply from a method error.- The second argument to
send_call/call_methodis aVec<OwnedFd>of file descriptors to pass along with the call — an empty vector in the common case. Similarly, received messages come back paired with any file descriptors that accompanied them; hence the(reply, _fds)tuple. - The reply types can borrow from the connection’s internal read buffer (note the lifetime
relationship in
receive_reply<'r, ...>) — deserialization can be zero-copy. Using&strfields instead ofStringin your reply types avoids allocations entirely.
Call also exposes the protocol flags as builder-style setters:
use zlink::Call;
#[derive(Debug, serde::Serialize)]
struct Ping;
// Fire-and-forget:
let call = Call::new(Ping).set_oneway(true);
// Ask for a stream of replies:
let call = Call::new(Ping).set_more(true);
For more calls, you keep invoking receive_reply until the received Reply’s continues()
method no longer returns Some(true).
The two halves
A Connection is really a pair of independent halves: a ReadConnection and a WriteConnection,
each owning its own buffer. You can split a connection into its halves and use them concurrently —
for instance, one task feeding calls while another consumes streaming replies:
fn example(conn: zlink::tokio::unix::Connection) {
let (mut read, mut write) = conn.split();
// `read.receive_reply(...)` and `write.send_call(...)` can now be driven
// from separate tasks. `Connection::join(read, write)` puts them back together.
}
Both halves share the connection’s unique id(), so they can be re-associated later.
Server-side connections
On the service side, you get Connections by accepting them from a listener — but you will
rarely do even that yourself, since zlink::Server handles the accept loop for you. We’ll cover
that in the service chapter.
Now that we know how connections work, let’s climb up the abstraction ladder — next stop: convenient, type-safe client proxies.