Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

zlink illustration

Introduction

zlink is a Rust crate for Varlink. If you are not familiar with Varlink, it is a simple inter-process communication (IPC) protocol: services and their clients exchange plain JSON messages over a socket. It was designed to be the simplest feasible way to make services accessible to both humans and machines, and it is seeing rapid adoption in the Linux plumbing layer — systemd alone ships more than a dozen Varlink services these days.

Unlike D-Bus, Varlink does not (typically) involve a broker or bus that relays messages between peers. Clients connect directly to the service they want to talk to, typically through a Unix domain socket. This point-to-point architecture is not just a simplification — as you will see throughout this book (especially in the Design for efficiency chapter), it enables zlink to offer an API that is leaner and more efficient than what is possible for a D-Bus implementation.

zlink is a 100% Rust-native, async-first implementation of the Varlink protocol. It provides:

  • a low-level API to send and receive Varlink messages over a connection,
  • high-level attribute macros — proxy for writing clients and service for writing services — that generate all the wiring for you,
  • first-class support for method call pipelining,
  • runtime introspection and code generation from Varlink IDL, and
  • no_std support in its core, for use in embedded environments.

Crate organization

The zlink project is a Cargo workspace consisting of several crates:

  • zlink: The main, unified API crate. This is the only crate you’ll typically want to depend on directly. It re-exports the appropriate subcrates based on the enabled cargo features.
  • zlink-core: The no_std-capable foundation: Connection, Call, Reply, Server, Service and friends. All the other crates build on this one.
  • zlink-macros: The attribute and derive macros.
  • zlink-tokio: tokio-based transport implementation and runtime integration.
  • zlink-smol: smol-based transport implementation and runtime integration.
  • zlink-idl: Varlink interface definition language (IDL) types and parser.
  • zlink-codegen: Generates Rust code from Varlink IDL files.

Since zlink re-exports everything you need, this book will only use the zlink crate in its examples. You enable your async runtime(s) through the additive cargo features tokio (the default) and smol, with the runtime-specific API of each residing in a module of the same name.

Getting help

If you need help using zlink, or just want to hang out with the cool kids, please come chat with us in the #zlink:matrix.org Matrix room. If something doesn’t seem right, please file an issue.

Some Varlink concepts to help newcomers

If you’re coming from the D-Bus world (perhaps even from zbus), many Varlink concepts will feel familiar — interfaces, methods, errors, introspection. But the differences are just as important as the similarities, so this chapter points them out along the way.

No bus — point-to-point connections

The most fundamental difference from D-Bus: there is no bus. A Varlink service listens on a socket (typically a Unix domain socket) and clients connect directly to it. There is no broker in the middle relaying messages, no handshake or authentication dance, no name registration and no service discovery through a central authority. A connection is just… a socket connection.

This has a couple of far-reaching consequences:

  • Connections are cheap. Establishing a Varlink connection is essentially the cost of a connect(2) call. There is no equivalent of the D-Bus authentication handshake and no bus-enforced limit on the number of connections a process may have (unless a specific service imposes one). Where a D-Bus process typically opens one connection to the bus and shares it between all its threads and tasks, a Varlink client is encouraged to open as many connections as it has independent conversations to conduct — for example, one per task.
  • Messages arrive in order and are never multiplexed. A service processes the calls it receives on a connection strictly in the order they were received, and replies in that same order. This is guaranteed by the Varlink specification and it’s what makes pipelining both possible and trivial. (The D-Bus specification makes no such sequential-processing guarantee.)

These two properties allow zlink to be dramatically simpler — and faster — on the inside than a D-Bus implementation can be. The Design for efficiency chapter explains how.

Services and addresses

Since there is no bus, there are no bus names either. A service is identified by the address of the socket it listens on. Varlink expresses addresses in URI notation, e.g.:

  • unix:/run/org.example.ftl — a Unix domain socket,
  • tcp:127.0.0.1:12345 — a TCP socket.

By convention, system services put their sockets in /run (e.g. systemd-resolved listens on /run/systemd/resolve/io.systemd.Resolve), while user-level services would typically use $XDG_RUNTIME_DIR. Socket files are usually named after the primary interface the service provides, which makes them discoverable with a simple ls.

Interfaces

An interface defines the API a service exposes, exactly like a D-Bus interface does. Interfaces are identified by reverse-domain names (e.g. io.systemd.Resolve or org.example.Calculator) and described in Varlink’s interface definition language (IDL). Here’s a small example:

interface org.example.Calculator

type CalculationResult (
    result: float
)

method Add(a: float, b: float) -> (result: float)
method Divide(dividend: float, divisor: float) -> (result: float)

error DivisionByZero(message: string)

A few things worth noting:

  • Methods take named parameters and return named parameters. Method names are in PascalCase.
  • Custom types (type) are structures with named, typed fields. The primitive types are bool, int, float, string, object (a foreign, untyped object) and any. Compound types include arrays ([]string), maps ([string]int), enums ((red, green, blue)) and anonymous structs.
  • Nullable types are spelled with a ? prefix: ?string. Yes — unlike D-Bus, Varlink has nullable types, so Rust’s Option<T> maps directly to the wire format. 🎉
  • Errors (error) are first-class citizens of the interface definition, with typed parameters of their own.

Unlike D-Bus, where the interface description (in XML) is mostly a machine-level detail, the Varlink IDL is designed to be written and read by humans. It is also available from the service itself at runtime, through introspection.

Method calls

All communication happens through method calls. A call is a JSON object with the fully-qualified method name and (optionally) its parameters:

{
  "method": "org.example.Calculator.Add",
  "parameters": { "a": 4.0, "b": 2.0 }
}

The reply is a JSON object as well:

{
  "parameters": { "result": 6.0 }
}

…unless the call failed, in which case the reply carries the fully-qualified error name instead:

{
  "error": "org.example.Calculator.DivisionByZero",
  "parameters": { "message": "Cannot divide by zero" }
}

Each message is terminated by a single NUL byte. That’s the whole wire format! One pleasant side-effect of the human-readable encoding: your IPC traffic doubles as a log. Dump the socket traffic and you can read exactly what your service is being asked and what it answered — no special decoding tools required.

A call can also carry a few flags that modify its behavior:

  • oneway: The client doesn’t want a reply. Fire and forget.
  • more: The client expects multiple replies to this one call — a stream. Each intermediate reply carries "continues": true; the final one doesn’t. This is Varlink’s replacement for D-Bus signals: instead of broadcasting to whoever might listen, a client explicitly subscribes by issuing a more call on a connection dedicated to that stream, and the service streams replies for as long as needed.
  • upgrade: The client wants to take over the connection with a custom protocol after this call. (Rarely used and out of scope for this book.)

Errors

As shown above, an error reply names the error in fully-qualified, reverse-domain form and can carry parameters, just like a method reply. Errors declared in the interface IDL are part of the API contract. In zlink, errors map naturally to Rust enums — you’ll see this in action in the client and service chapters.

Introspection

Every well-behaved Varlink service implements the standard org.varlink.service interface, which offers two methods:

  • GetInfo — returns metadata about the service (vendor, product, version, URL) and the list of interfaces it implements.
  • GetInterfaceDescription — returns the IDL text of a given interface.

That’s considerably simpler than D-Bus introspection, and just as with zbus, zlink’s service macro implements it for you automatically, while both a low-level and a high-level client API is available for querying it.

Exploring services with varlinkctl

Just like busctl for D-Bus, systemd ships a handy CLI tool for poking at Varlink services: varlinkctl (available since systemd v255). A few examples to try on a recent Linux system:

# What does systemd-resolved offer?
varlinkctl info /run/systemd/resolve/io.systemd.Resolve

# Show the IDL of the io.systemd.Resolve interface
varlinkctl introspect /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve

# Call a method
varlinkctl call /run/systemd/resolve/io.systemd.Resolve \
  io.systemd.Resolve.ResolveHostname '{"name": "systemd.io", "family": 2}'

We’ll use varlinkctl throughout this book to interact with the services we build.

Good practices & API design

Just as with D-Bus, it is recommended to organize interface names by using fully-qualified, reverse domain names you control, in order to avoid conflicts. Design your methods around named parameters and declare all the errors they can produce in the IDL — your future users (and your future self) will thank you.

Onwards to implementation details & examples!

Establishing 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 underlying receive_reply) is generic over the reply and the error type. The result is a nested Result: the outer zlink::Result covers connection-level problems (I/O errors, protocol violations, etc. — anything that isn’t an answer from the service) while the inner reply::Result<Params, Error> distinguishes a successful reply from a method error.
  • The second argument to send_call/call_method is a Vec<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 &str fields instead of String in 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.

Writing a client proxy

In this chapter, we are going to see how to write a convenient, type-safe client for a Varlink service using the proxy attribute macro.

To make this learning “hands-on”, we are going to talk to a real service: systemd-resolved, the DNS resolver service of systemd, which exposes the io.systemd.Resolve Varlink interface.

Let’s start by playing with the service from the shell with varlinkctl:

varlinkctl call /run/systemd/resolve/io.systemd.Resolve \
  io.systemd.Resolve.ResolveHostname '{"name": "systemd.io", "family": 2}'

You should get something like:

{
        "addresses" : [
                {
                        "ifindex" : 2,
                        "family" : 2,
                        "address" : [ 185, 199, 111, 153 ]
                }
        ],
        "name" : "systemd.io",
        "flags" : 1048577
}

This command shows us all the elements of a Varlink method call:

  • address: the path of the socket the service listens on (/run/systemd/resolve/io.systemd.Resolve).
  • method: the fully-qualified method name — interface (io.systemd.Resolve) + method (ResolveHostname).
  • parameters: a JSON object. Since the wire format is JSON, what you type is exactly what the service receives.

We saw in the previous chapter how to make such a call with the low-level Connection API. It works, but declaring method enums and matching up reply types by hand is verbose and error-prone. Let’s have a macro do it for us.

Trait-derived proxy

The proxy attribute macro takes a trait declaration and implements it directly on Connection:

use serde::Deserialize;
use zlink::{proxy, ReplyError};

#[proxy("io.systemd.Resolve")]
trait ResolveProxy {
    async fn resolve_hostname(
        &mut self,
        name: &str,
    ) -> zlink::Result<Result<ResolveHostnameReply, ResolveError>>;
}

#[derive(Debug, Deserialize)]
struct ResolveHostnameReply {
    addresses: Vec<ResolvedAddress>,
}

#[derive(Debug, Deserialize)]
struct ResolvedAddress {
    family: i32,
    address: Vec<u8>,
}

#[derive(Debug, ReplyError)]
#[zlink(interface = "io.systemd.Resolve")]
enum ResolveError {
    NoNameServers,
    NoSuchResourceRecord,
    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?;

    // The proxy methods are available directly on the connection.
    match conn.resolve_hostname("systemd.io").await? {
        Ok(reply) => println!("{:?}", reply.addresses),
        Err(e) => eprintln!("lookup failed: {e:?}"),
    }

    Ok(())
}

If you’re coming from zbus, notice what’s missing here: there is no proxy object. In zbus you create a Proxy value that wraps a (shared) connection; in zlink the generated trait is implemented for Connection<S> itself, so once the trait is in scope, you call interface methods directly on your connection. A connection is a client. (The flip side: since the trait needs to be in scope, be mindful of what you import — two traits declaring the same method name will require disambiguation.)

The snake_case method names are automatically converted to PascalCase for the wire format (resolve_hostnameResolveHostname). Use #[zlink(rename = "...")] on a method when the default mapping doesn’t fit.

A proxy method must:

  • take &mut self (remember: exclusive access, no locks — see Design for efficiency),
  • return zlink::Result<Result<ReplyParams, ReplyError>> — the same nested-Result shape we met in the previous chapter: transport-level errors on the outside, method errors from the service on the inside,
  • use parameter types that implement Serialize, and reply/error types that implement Deserialize.

Reply types may borrow from the connection’s read buffer for zero-copy deserialization — just give them a lifetime parameter (e.g. Result<Status<'_>, MyError<'_>>). We used owned types (Vec<u8>) above for simplicity.

Reply errors

The ReplyError derive macro maps a Rust enum to Varlink error replies of an interface. The mandatory interface attribute provides the reverse-domain prefix; the variant name is the error name; named fields become the error’s parameters:

use zlink::ReplyError;

#[derive(Debug, ReplyError)]
#[zlink(interface = "org.example.ftl")]
enum FtlError {
    // -> {"error": "org.example.ftl.NotEnoughEnergy"}
    NotEnoughEnergy,
    // -> {"error": "org.example.ftl.ParameterOutOfRange", "parameters": {"field": "..."}}
    ParameterOutOfRange { field: String },
}

Variant and field names can be adjusted with #[zlink(rename = "...")] / rename_all, and string fields can borrow zero-copy with #[zlink(borrow)] on a Cow<'_, str> field. Tuple variants are not supported — Varlink error parameters are named, so use struct variants.

Streaming calls

Where D-Bus has signals and properties-changed notifications, Varlink has more calls: a single call that yields a stream of replies. On the proxy side, mark the method with #[zlink(more)] and return a Stream:

use futures_util::Stream;
use zlink::proxy;
#[derive(Debug, serde::Deserialize)]
struct DriveCondition { state: String }
#[derive(Debug, zlink::ReplyError)]
#[zlink(interface = "org.example.ftl")]
enum FtlError { NotEnoughEnergy }

#[proxy("org.example.ftl")]
trait FtlProxy {
    #[zlink(more)]
    async fn monitor_drive_condition(
        &mut self,
    ) -> zlink::Result<impl Stream<Item = zlink::Result<Result<DriveCondition, FtlError>>>>;
}

Each stream item is one reply. Note that the reply and error types of a streaming method must be owned (DeserializeOwned): the connection’s read buffer is reused from one stream item to the next, so borrowed data can’t outlive a single iteration.

Since a streaming call occupies its connection for as long as the stream lives, the idiomatic pattern is to dedicate a connection to each subscription and open another one for your request/response traffic. Connections are cheap — this is the Varlink way. ✨

Other method attributes

  • #[zlink(oneway)]: fire-and-forget; the method must return zlink::Result<()> since there is no reply at all.
  • #[zlink(rename = "...")] on a parameter: customize the wire name of that parameter.
  • #[zlink(fds)] on a Vec<OwnedFd> parameter: pass file descriptors along with the call. #[zlink(return_fds)] on the method makes it also return the descriptors that came with the reply. As on D-Bus, the convention is for the JSON parameters to carry indexes into the descriptor array.
  • Methods can be generic over their parameter types, with the usual Serialize bounds.

Pipelining

The macro additionally generates a chain_<method> variant of every method with owned reply types, letting you batch several calls into a single write and read the replies as a stream. That deserves its own chapter: see Pipelining method calls.

Generating the proxy from IDL

If the service you’re binding publishes its IDL (via introspection or as a .varlink file), you don’t have to write any of the above by hand — zlink-codegen can generate the trait, types, and error enums for you.

Writing a service

In this chapter, we are going to implement a small service with a method SayHello, to greet back the calling client — and then grow it into something more interesting.

No name to take — just bind a socket

Readers coming from D-Bus may expect a section here about requesting a well-known name on the bus and its associated pitfalls. There is no such thing in the Varlink world: your service is the socket it listens on. Setting up shop is exactly two steps — bind a listener and hand it to a Server together with your service implementation:

struct Greeter;
#[derive(Debug, serde::Serialize, serde::Deserialize, zlink::introspect::Type)]
struct Greeting { greeting: String }
#[zlink::service(interface = "org.zlink.MyGreeter")]
impl Greeter {
    async fn say_hello(&self, name: String) -> Greeting {
        Greeting { greeting: format!("Hello {name}!") }
    }
}
async fn example(greeter: Greeter) -> Result<(), Box<dyn std::error::Error>> {
let listener = zlink::tokio::unix::bind("/run/user/1000/org.zlink.MyGreeter")?;
let server = zlink::Server::new(listener, greeter);
server.run().await?;
Ok(())
}

There is no “hang on, incoming calls might be lost” window either: as soon as bind returns, the socket exists and clients can connect; their calls simply queue up until run starts servicing them.

The service macro

The service attribute macro turns an ordinary impl block into a full Varlink service — it generates the message handling, method dispatch, and introspection support:

use zlink::{service, tokio::unix, Server};
use serde::{Deserialize, Serialize};
use zlink::introspect;

struct Greeter;

#[derive(Debug, Serialize, Deserialize, introspect::Type)]
struct Greeting {
    greeting: String,
}

#[service(interface = "org.zlink.MyGreeter")]
impl Greeter {
    async fn say_hello(&self, name: &str) -> Greeting {
        Greeting {
            greeting: format!("Hello {name}!"),
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let socket_path = std::env::var("XDG_RUNTIME_DIR").map(std::path::PathBuf::from)?
        .join("org.zlink.MyGreeter");
    let listener = unix::bind(&socket_path)?;
    Server::new(listener, Greeter).run().await?;

    Ok(())
}

Let’s greet the service from the shell:

$ varlinkctl call $XDG_RUNTIME_DIR/org.zlink.MyGreeter org.zlink.MyGreeter.SayHello '{"name": "zlink"}'
{
        "greeting" : "Hello zlink!"
}

And it can introspect itself, out of the box:

$ varlinkctl introspect $XDG_RUNTIME_DIR/org.zlink.MyGreeter org.zlink.MyGreeter
interface org.zlink.MyGreeter

method SayHello(name: string) -> (greeting: string)

Some things to note:

  • Method names are converted from snake_case to PascalCase on the wire, parameters keep their names (#[zlink(rename = "...")] overrides either).
  • Since Varlink methods return named output parameters and Rust has no anonymous structs, the reply is wrapped in a struct deriving introspect::Type; the macro unwraps its fields into the method’s output parameters (-> (greeting: string)), both on the wire and in the IDL above.
  • Types that should appear as named custom types in the IDL derive introspect::CustomType instead, and must be listed in types = [...] on the macro. Forgetting one is a compile-time error, so you can’t accidentally publish an incomplete interface description.
  • The interface can also be specified per-method with #[zlink(interface = "...")]; it propagates to subsequent methods, which is how a single service can implement multiple interfaces.

Server::run and tokio::spawn

Due to a compiler bug, the future returned by Server::run cannot currently be Send, which means you can’t tokio::spawn(server.run()). Run it on a LocalSet/LocalRuntime (or a dedicated thread), or drive it concurrently with your other work using select!:

async fn example<Svc: zlink::Service<zlink::tokio::unix::Stream>>(
    server: zlink::Server<zlink::tokio::unix::Listener, Svc>,
) -> Result<(), Box<dyn std::error::Error>> {
async fn do_other_things() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }
tokio::select! {
    res = server.run() => res?,
    res = do_other_things() => res?,
}
Ok(())
}

The examples and tests in the zlink repository all follow this pattern.

Service state: mutable, without locks

Here’s something that tends to raise eyebrows when people first see it. Let’s give our service some state:

use zlink::{introspect, service};
#[derive(Debug, serde::Serialize, serde::Deserialize, introspect::Type)]
struct Count { count: u64 }
struct Counter {
    count: u64,
}

#[service(interface = "org.zlink.Counter")]
impl Counter {
    async fn increment(&mut self) -> Count {
        self.count += 1; // <- plain mutation. No Mutex. No RwLock. No atomics.
        Count { count: self.count }
    }
}

That’s a &mut self method mutating a plain field — no Arc<Mutex<...>> in sight. In zbus, an interface method blocking on &mut self for too long famously stalls all other method calls on that interface, and the recommended fix is interior mutability. zlink takes the opposite stance, which we call exterior mutability: the Server drives all connections from a single task, so at any moment at most one method handler is running, and it may freely get exclusive access to the state. The compiler statically guarantees there is no concurrent access — no locks, no lock contention, no deadlocks, at zero runtime cost.

“But doesn’t one task limit throughput?” For the vast majority of control-plane services, no — and the Design for efficiency chapter explains why this model, combined with cheap connections and pipelining, usually comes out ahead. If a method genuinely needs long-running work, spawn a task for it and stream progress back through a streaming method.

Method errors

To return errors from methods, declare an error enum (the same ReplyError + introspect::ReplyError pair we saw in the client chapter) and return a Result:

use zlink::{ReplyError, introspect, service};

#[derive(Debug, ReplyError, introspect::ReplyError)]
#[zlink(interface = "org.zlink.Counter")]
enum CounterError {
    Overflow,
}

struct Counter { count: u64 }
#[derive(Debug, serde::Serialize, serde::Deserialize, introspect::Type)]
struct Count { count: u64 }
#[service(interface = "org.zlink.Counter")]
impl Counter {
    async fn increment(&mut self) -> Result<Count, CounterError> {
        self.count = self.count.checked_add(1).ok_or(CounterError::Overflow)?;
        Ok(Count { count: self.count })
    }
}

An Err return is sent as a Varlink error reply — for the wire format, see the concepts chapter. Different methods can use different error types; the macro unifies them internally.

Streaming methods

A method annotated with #[zlink(more)] implements Varlink’s streaming — the service-side counterpart of what D-Bus offers through signals and property-change notifications. Such a method:

  • takes more: bool as its first parameter (after self) — the flag from the incoming call,
  • returns a Stream of Reply values (each with continues set appropriately).

The notified::State helper makes the most common case — “let clients monitor a value” — a one-liner. Here’s the relevant part of the FTL (faster-than-light drive 🚀) example from zlink’s test suite:

use zlink::tokio::notified::{self, traits::State as _};
use zlink::service;
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, zlink::introspect::CustomType)]
struct DriveCondition { tylium_level: i64 }

struct Ftl {
    drive_condition: notified::State<DriveCondition, DriveCondition>,
}

#[service(interface = "org.example.ftl", types = [DriveCondition])]
impl Ftl {
    /// When `more` is false, reply once with the current condition;
    /// when true, stream every change as it happens.
    #[zlink(more)]
    async fn get_drive_condition(&self, more: bool) -> notified::Stream<DriveCondition> {
        if more {
            self.drive_condition.stream()
        } else {
            self.drive_condition.stream_once()
        }
    }

    async fn set_drive_condition(&mut self, condition: DriveCondition) -> DriveCondition {
        self.drive_condition.set(condition).await;
        condition
    }
}

Every client that called GetDriveCondition with the more flag receives a new reply whenever set is called. While a stream is active, the Server keeps serving other calls and other connections; the streaming connection is simply parked in the server’s event loop.

For full control, a streaming method can also return any custom impl Stream<Item = Reply<T>> (or Item = Result<Reply<T>, E> to be able to emit errors mid-stream), constructing replies with Reply::new(...).set_continues(...) yourself.

A client can monitor the drive condition with:

varlinkctl call --more /run/org.example.ftl org.example.ftl.GetDriveCondition '{}'

Introspection for free

The macro implements the standard org.varlink.service interface for you:

  • GetInfo returns the service metadata you declare on the macro:

    struct Ftl;
    #[derive(Debug, serde::Serialize, serde::Deserialize, zlink::introspect::Type)]
    struct Speed { speed: i64 }
    #[zlink::service(
        interface = "org.example.ftl",
        vendor = "The FTL project",
        product = "FTL-capable Spaceship 🚀",
        version = "1",
        url = "https://example.org/ftl",
    )]
    impl Ftl {
        // ...methods...
        async fn get_speed(&self) -> Speed { Speed { speed: 42 } }
    }
  • GetInterfaceDescription returns the IDL of any implemented interface, generated at compile time from your method signatures and the types = [...] list.

  • Calls to unknown methods or interfaces automatically get the standard MethodNotFound / InterfaceNotFound error replies.

Socket activation and inherited sockets

Beyond unix::bind, a Listener can be created from an inherited file descriptor via TryFrom<OwnedFd> — handy for systemd socket activation (Accept=no). For the Accept=yes style (and varlinkctl’s exec: addresses), where the process is handed a single already-connected socket, ReadyListener wraps that one connection; Server::run then serves it and returns cleanly when the client disconnects.

Under the hood: the Service trait

The service macro is sugar for implementing the zlink::Service trait, whose handle method receives each incoming Call and returns a MethodReply (single reply, error, or stream). You can implement it by hand for maximum control — the macro-generated introspection then becomes your responsibility — but for nearly all services, the macro is the way to go.

Pipelining method calls

Suppose you need to make ten method calls. The obvious way costs you ten round trips: send a call, sleep until the reply wakes you up, send the next… Every one of those wake-ups is a context switch, and as it turns out, context switches — not message parsing — are where IPC performance goes to die.

Varlink’s answer is pipelining: because the specification guarantees that a service processes calls in the order received and replies in that same order, a client can safely fire off a whole batch of calls and then collect the replies, in order, afterwards. (Fun fact: the D-Bus specification makes no such ordering guarantee, so a D-Bus client can never quite pull this off safely.)

zlink treats pipelining as a first-class feature at every level of the API.

Chaining proxy methods

For every proxy method with owned reply types, the proxy macro generates a chain_<method> variant that starts a chain, plus a companion trait (<TraitName>Chain) that lets you keep appending calls to it. Nothing hits the wire until you call send, which writes the entire batch at once and hands you back a stream of replies:

use futures_util::{StreamExt, pin_mut};
use zlink::proxy;
#[derive(Debug, serde::Deserialize)]
struct ResolveHostnameReply { addresses: Vec<serde_json::Value> }
#[derive(Debug, zlink::ReplyError)]
#[zlink(interface = "io.systemd.Resolve")]
enum ResolveError { NoNameServers }

#[proxy("io.systemd.Resolve")]
trait ResolveProxy {
    async fn resolve_hostname(
        &mut self,
        name: &str,
    ) -> zlink::Result<Result<ResolveHostnameReply, ResolveError>>;
}

#[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?;

    // Three DNS lookups, one write, one batch of replies.
    let replies = conn
        .chain_resolve_hostname("example.com")?
        .resolve_hostname("systemd.io")?
        .resolve_hostname("varlink.org")?
        .send::<ResolveHostnameReply, ResolveError>()
        .await?;

    pin_mut!(replies);
    while let Some(reply) = replies.next().await {
        let (reply, _fds) = reply?;
        match reply {
            Ok(reply) => println!("{:?}", reply.into_parameters()),
            Err(e) => eprintln!("error: {e:?}"),
        }
    }

    Ok(())
}

(This is essentially the resolved example shipped in the repository — try cargo run --example resolved -- example.com systemd.io.)

Things to know about chains:

  • Replies arrive in call order — that’s the protocol guarantee that makes this work.

  • Owned reply types only. send::<Params, Error>() requires DeserializeOwned types because the connection’s read buffer is reused between stream items, so replies can’t borrow from it. This is why the macro only generates chain_ variants for methods with owned reply types (input parameters may still borrow all they like).

  • One reply type for the whole chain. All replies in a chain deserialize into the same Params/Error type pair. When chaining different methods — even across different interfaces and proxy traits on the same connection — declare an untagged enum that covers all the possible reply shapes:

    #[derive(Debug, serde::Deserialize)] struct User;
    #[derive(Debug, serde::Deserialize)] struct Post;
    #[derive(Debug, serde::Deserialize)]
    #[serde(untagged)]
    enum BlogReply {
        User(User),
        Post(Post),
    }
  • Oneway calls chain too, and consume no reply slot: a chain of three calls where one is oneway yields exactly two replies.

Low-level chaining

The same machinery is available on Connection directly, no macros involved:

use zlink::Call;
async fn example(
    conn: &mut zlink::tokio::unix::Connection,
    get_user: Call<u32>, get_project: Call<u32>,
) -> zlink::Result<()> {
#[derive(Debug, serde::Deserialize)] struct User;
#[derive(Debug, zlink::ReplyError)]
#[zlink(interface = "org.example")] enum ApiError { NotFound }
use futures_util::{pin_mut, stream::StreamExt};

let replies = conn
    .chain_call(&get_user, vec![])?
    .append(&get_project, vec![])?
    .send::<User, ApiError>()
    .await?;
pin_mut!(replies);
while let Some(reply) = replies.next().await {
    let (reply, _fds) = reply?;
    // ...
}
Ok(())
}

Chains have no length limit — you can append in a loop, or build one straight from an iterator with chain_from_iter (and chain_from_iter_with_fds when file descriptors are involved):

use serde::{Serialize, Deserialize};
use serde_prefix_all::prefix_all;
#[prefix_all("org.example.")]
#[derive(Debug, Serialize)]
#[serde(tag = "method", content = "parameters")]
enum Methods { GetUser { id: u32 } }
#[derive(Debug, Deserialize)] struct User;
#[derive(Debug, zlink::ReplyError)]
#[zlink(interface = "org.example")] enum ApiError { NotFound }
async fn example(conn: &mut zlink::tokio::unix::Connection) -> zlink::Result<()> {
let user_ids = [1, 2, 3, 4, 5];
let replies = conn
    .chain_from_iter::<Methods, _, _>(user_ids.iter().map(|&id| Methods::GetUser { id }))?
    .send::<User, ApiError>()
    .await?;
Ok(())
}

And at the very bottom sit WriteConnection::enqueue_call (serialize a call into the write buffer without flushing) and flush (write out everything queued) — which is all a chain really is.

What you actually save

A chain of N calls performs a single write() system call for the whole batch instead of N, and the client typically gets scheduled once when the replies come in instead of N times. On the read side, zlink drains all complete replies available on the socket in one read(), so a batch of small replies often costs just one more syscall. The wire bytes are identical to sequential calls — this is purely about syscalls and context switches, which dominate the cost of small-message IPC. zlink’s benchmark suite (zlink-core/benches) includes a sequential-vs-pipelined comparison if you want numbers for your own machine.

The next chapter puts this in the broader context of zlink’s design.

Design for efficiency

zlink is not “zbus with JSON instead of a binary format”. The Varlink protocol’s point-to-point architecture allows for a fundamentally different — and leaner — design than what is possible for any D-Bus implementation. This chapter explains that design and the reasoning behind it. If you’ve ever wondered why zlink’s API insists on &mut self everywhere, or why this book keeps repeating “just open another connection”, here is the payoff.

Much of this chapter is based on the talk Forget zbus, zlink is the future of IPC in Rust given by zlink’s author at All Systems Go! 2025 (recording also on media.ccc.de).

The problem with a shared connection

In the D-Bus world, a process typically opens one connection to the bus and shares it between all its threads and tasks. This isn’t just habit: connections are relatively expensive to establish (authentication handshake, hello dance) and the bus enforces per-user connection limits, so a library can’t just open connections liberally.

Sharing a connection has deep consequences for a library’s architecture. Consider how zbus must receive messages: a dedicated socket reader task reads each message off the shared socket and broadcasts it to whichever tasks are interested. To avoid deep-copying every message to every consumer, messages get wrapped in Arcs. And since many tasks hold references to the same connection state, that state needs interior mutability — mutexes and atomics. Add it all up and every incoming message pays for:

  • at least two copies (socket → read buffer → parsed message), regardless of how many consumers there are;
  • atomic reference counting on every message (cheap, but not free — especially off the big-core x86 happy path);
  • lock acquisition on shared connection state, with the contention — and in the worst case, deadlock potential — that mutexes always bring;
  • an extra task-scheduling hop: the socket reader runs, then the consuming task gets woken up.

None of this is a zbus design flaw. It’s the price of the one-shared-connection model that D-Bus imposes, and every D-Bus library in every language pays it in some currency or other.

Varlink connections are cheap: connecting is a bare connect(2) — no handshake, no registration — and with no broker in the middle, there’s no bus-imposed connection limit either. The protocol, in fact, expects clients to open multiple connections to the same service — that is e.g. how you have concurrent method calls in flight, given that each connection is a strictly ordered request/reply pipeline.

Cheap connections dissolve the entire problem from the previous section: if every task can afford its own connection, then nothing about a connection ever needs to be shared.

Exterior mutability

zlink leans into this completely. A Connection is exclusively owned, and every operation on it takes &mut self. We call this design exterior mutability — mutation happens through plain exclusive references, with Rust’s borrow checker proving at compile time that no two contexts ever touch the same connection state concurrently. Compare:

shared connection (zbus model)connection per task (zlink model)
socket readingcentral socket-reader taskeach task reads its own socket
message copiestwo + Arc per consumerone, into the connection’s buffer
string/data accessowned/Arc’d datazero-copy borrows from the buffer
shared statemutexes + atomicsnone — &mut proves exclusivity
deadlockspossibleimpossible (nothing to lock)
wake-ups per messagereader task + consumer taskconsumer only

Because replies deserialize straight out of the connection’s read buffer, reply types can borrow (&str fields, Cow<'_, str>, lifetime-parameterized structs) and the common read-a-reply-and-look-at-it path performs no allocation and no copy beyond the single socket-to-buffer read. No atomics anywhere. No mutexes anywhere. The type system does at compile time what locks would otherwise do at runtime.

This is also why the proxy macro implements the generated trait directly on Connection rather than on some proxy object wrapping a shared handle: the connection is the client, and holding &mut to it is all the synchronization there will ever be.

And when you do want concurrency? Open another connection — one per logical conversation, e.g. one for your streaming subscription and one for request/response calls. Each is independent, each reads its own socket, and none of them needs to coordinate with any other.

A server without locks

The same philosophy runs through the service side. Server::run drives all connections — new accepts, incoming calls, and outgoing reply streams — from a single task using a fair, round-robin-ish select loop. At most one method handler executes at any moment, so the handler can take &mut self on your service and mutate plain fields directly. Your service state needs no Arc<Mutex<...>>, and the dispatch loop itself acquires no locks — connections just sit in vectors, polled fairly.

A single task sounds like a bottleneck; for the small-message control-plane traffic that IPC overwhelmingly consists of, it isn’t — the per-message work is tiny, and eliminating locks, atomics, copies, and cross-task hand-offs buys back much more than parallel handlers would. Compute-heavy methods can always spawn work off and stream results back.

Pipelining, guaranteed

Varlink’s strict ordering guarantee (calls are processed, and replies delivered, in order) makes pipelining safe by construction — batch N calls into one write(), get woken once for the replies. The D-Bus specification never made this promise, so D-Bus clients are stuck with call-and-wait round trips. When systemd’s developers measured D-Bus performance, batching was one of the biggest wins available — Varlink builds it into the protocol contract, and zlink surfaces it as the chaining API at every level.

“But JSON is slow!”

That was zlink’s author’s first objection too, and it deserves an honest answer.

Parsing JSON does cost more than parsing a well-designed binary format. But for IPC-sized messages what dominates end-to-end cost is not parsing — it’s context switching, which modern CPUs are comparatively bad at (and mitigations for speculative-execution vulnerabilities made worse). Benchmarks comparing JSON against D-Bus’s binary format (data here) show that for typical D-Bus-style payloads — dictionaries with named keys, exactly what most real-world IPC messages look like — JSON’s speed and size are in the same ballpark; the binary format only pulls meaningfully ahead for large arrays of raw values. Meanwhile the JSON choice buys:

  • serde_json — one of the most mature, optimized and battle-tested serialization libraries in the Rust ecosystem, for free;
  • your traffic is your log — captured Varlink traffic is human-readable as-is, no decoder needed;
  • nullable typesOption<T> maps directly onto Varlink’s ?type, something D-Bus never had.

And where message framing costs could bite — locating the NUL terminators in a buffer full of pipelined messages — zlink lets the deserializer report where each message ends instead of re-scanning the buffer, and drains every complete message from a single read().

The scorecard

Putting it together, on a typical method call zlink’s client path does: one buffered write() (shared with any pipelined friends), one read() into a connection-owned buffer, one zero-copy deserialization borrowing from that buffer — with no locks taken, no atomics touched, no reference counts bumped, and no intermediate task woken. It is hard to see what’s left to remove — which is, in the end, the best summary of zlink’s design philosophy: the fastest work is the work you never do.

Introspection & code generation

We’ve seen that the service macro implements the standard org.varlink.service introspection interface for you. This chapter covers the other side — how to consume introspection from a client — and how to turn IDL into ready-made Rust code.

The relevant cargo features are:

  • idl — the Rust representation of Varlink IDL (zlink::idl module: Interface, Method, Type and friends);
  • idl-parse — parsing IDL text into those types at runtime (works in no_std, too);
  • introspection — the introspect module: traits and derives that describe Rust types in IDL terms at compile time, plus the org.varlink.service client support.

Querying a service

The zlink::varlink_service module ships a ready-made proxy for org.varlink.service. Since proxy traits are implemented directly on Connection, any connection can be introspected — just bring the trait into scope:

use zlink::varlink_service::Proxy as _;

#[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?;

    // Who are you?
    let info = conn.get_info().await?.map_err(|e| e.to_string())?;
    println!("{} {} (by {})", info.product, info.version, info.vendor);
    for interface in &info.interfaces {
        println!("  implements {interface}");
    }

    // Tell me about that interface.
    let description = conn
        .get_interface_description("io.systemd.Resolve")
        .await?
        .map_err(|e| e.to_string())?;
    let interface = description.parse()?; // -> zlink::idl::Interface (needs `idl-parse`)
    for method in interface.methods() {
        println!("method {}", method.name());
    }

    Ok(())
}

The parsed Interface gives you programmatic access to everything in the IDL: methods with their parameters, custom types (interface.custom_types()), errors (interface.errors()), and doc comments. The repository’s varlink-inspect example is a tiny varlinkctl introspect clone built on exactly this API:

cargo run --example varlink-inspect --features="introspection idl-parse" -- \
  /run/systemd/resolve/io.systemd.Resolve

Describing your types: the introspect derives

For the compile-time direction — describing Rust types in IDL terms so the service macro can generate interface descriptions — the introspect module provides three derives:

  • introspect::Type for structs and enums used as parameters or replies;
  • introspect::CustomType for types that should appear as named custom types in the IDL;
  • introspect::ReplyError for error enums (derived alongside zlink::ReplyError).

You’ve already seen them in earlier chapters; they all support #[zlink(rename)]/rename_all to control the IDL-level naming.

Code generation

Writing proxy traits and types by hand is fine for small interfaces, but when a service publishes its IDL, you can generate the Rust side mechanically with zlink-codegen — zlink’s analog of zbus’s zbus_xmlgen:

# Install the code generator
cargo install zlink-codegen

# Get the IDL of a running service...
varlinkctl introspect /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve \
  > io.systemd.Resolve.varlink

# ...and generate Rust code from it
zlink-codegen io.systemd.Resolve.varlink > src/resolve.rs

For each interface, the generated code contains a #[proxy] trait with one method per IDL method, structs for the custom types (and for multi-value method returns), and a ReplyError enum covering the interface’s errors. IDL types map the way you’d expect: ?TOption<T>, []TVec<T>, [string]THashMap<String, T>; input parameters use borrowed types (&str, &[T]) for zero-copy calls.

From a build script

zlink-codegen is also a library, so you can regenerate bindings on every build instead of committing them:

// build.rs
use std::{env, path::PathBuf};

fn main() {
    let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
    let idl = PathBuf::from(&manifest_dir).join("io.systemd.Resolve.varlink");
    println!("cargo:rerun-if-changed={}", idl.display());

    let out = PathBuf::from(env::var("OUT_DIR").unwrap()).join("generated.rs");
    zlink_codegen::generate_files(&zlink_codegen::CodegenOptions {
        files: vec![idl],
        output: Some(out),
        rustfmt: true,
        ..Default::default()
    })
    .expect("Failed to generate code");
}

…and then pull the generated code into your crate with include!(concat!(env!("OUT_DIR"), "/generated.rs")); — the test-integration crate in the zlink repository demonstrates this exact setup end to end.

As with zbus_xmlgen, treat generator output as a starting point when hand-tuning is warranted — e.g. to use richer Rust types than the IDL can express — and as a fully-automatic solution when it isn’t.

no_std & embedded usage

One of zlink’s founding goals is to bring Varlink to places no D-Bus implementation can go: microcontrollers. Picture a device where firmware on a microcontroller talks to a Linux “brain” board — traditionally that means a gateway process translating between some ad-hoc serial protocol and the system IPC. If the microcontroller could speak the IPC protocol itself, a layer of middlemen disappears. Varlink’s minimal wire format (JSON + NUL framing over any byte stream) makes that realistic, and zlink’s core is built to deliver it: zlink-core is no_std-compatible, avoids allocations wherever possible, and — true to form — uses no atomics and no interior mutability, which also makes it well-behaved on single-core embedded targets.

What works without std

The no_std surface is the client side and the plumbing underneath it:

  • Connection, Call, Reply and the whole send/receive machinery,
  • the proxy macro — type-safe clients work fully in no_std,
  • chaining/pipelining,
  • IDL types and parsing (idl, idl-parse features),
  • logging via defmt instead of tracing.

The server side (Server, Listener, the service macro) and file-descriptor passing currently require std.

To use zlink in no_std, depend on zlink-core directly (the zlink crate’s runtime integrations are std-only) and disable default features:

[dependencies]
zlink-core = { version = "0.7", default-features = false, features = ["proxy", "idl-parse", "defmt"] }

Note that either tracing or defmt must be enabled — for no_std, that means defmt. This configuration is exercised by zlink’s CI, so it is guaranteed to keep building:

cargo check -p zlink-core --no-default-features --features idl-parse,proxy,defmt

Note that no_std does not (currently) mean no_alloc: zlink-core relies on the alloc crate (e.g. for its message buffers), so your target needs a global allocator. Allocations are nonetheless kept to a minimum — reply deserialization borrows from the connection’s read buffer rather than copying out of it.

Bringing your own transport

On a Linux system, the transport is a Unix socket provided by zlink-tokio/zlink-smol. On an embedded target, you provide the transport, by implementing three small traits from zlink_core::connection::socket:

use zlink_core::connection::socket::{Socket, ReadHalf, WriteHalf, ReadResult};

#[derive(Debug)]
struct UartSocket { /* ... */ }
#[derive(Debug)]
struct UartRead { /* ... */ }
#[derive(Debug)]
struct UartWrite { /* ... */ }

impl Socket for UartSocket {
    type ReadHalf = UartRead;
    type WriteHalf = UartWrite;

    fn split(self) -> (UartRead, UartWrite) {
        // hand out the two directions of your transport
        todo!()
    }
}

impl ReadHalf for UartRead {
    async fn read(&mut self, buf: &mut [u8]) -> zlink_core::Result<ReadResult> {
        // read some bytes into `buf`
        todo!()
    }
}

impl WriteHalf for UartWrite {
    async fn write(&mut self, buf: &[u8]) -> zlink_core::Result<()> {
        // write all of `buf`
        todo!()
    }
}

That’s the entire integration contract. Connection::new(UartSocket { .. }) then gives you everything this book has covered on the client side — proxies, chains, zero-copy replies — over your UART (or SPI, or USB CDC, or RTT…). Varlink deliberately doesn’t prescribe the byte stream underneath; anything that can carry bytes in order will do.

Two requirements to keep in mind for the async plumbing: the futures returned by read/write must be cancel-safe, and they should behave as Unpin would require — zlink avoids boxing them (that would mean allocation), so they get polled in place.

For tests — embedded or not — zlink-core also ships a mock transport, zlink_core::test_utils::mock_socket::MockSocket, that replays pre-configured replies; the codegen integration tests use it to exercise generated proxies without any real socket.

An honest word on scope

Should you build your safety-critical, hard-real-time control loop on JSON-over-serial? No — a protocol with variable-length human-readable framing is not where you go for guaranteed latency, and zlink won’t pretend otherwise. The sweet spot for embedded Varlink is the management plane: configuration, status queries, monitoring streams — the same request/reply and subscribe patterns this book has covered throughout, now reaching all the way down to the firmware. And thanks to zero-copy deserialization and the no-atomics, allocation-averse discipline in the core, the footprint stays appropriately tiny.

FAQ

Both are maintained by the same project, so this is an honest referee’s answer: it depends on which protocol you need to speak, and that’s usually decided for you.

  • Talking to an existing D-Bus service (desktop APIs, NetworkManager, BlueZ, …) → zbus.
  • Talking to an existing Varlink service (a growing list of systemd components, …) → zlink.
  • Designing a new system service or a point-to-point IPC link and free to choose? Varlink and zlink make a compelling default: no broker dependency, a simpler protocol, and the efficiency benefits that come with it. If you need what a bus genuinely provides — broadcast signals to many unrelated listeners, discovery/enumeration of running services, bus-enforced policy — D-Bus still earns its keep.

Where are the signals and properties?

Varlink doesn’t have them as separate concepts — both patterns are served by streaming (more) calls. A property is just a method returning the value; a “property changed” signal or any other notification is a more call streaming updates to each interested client on its own connection. There is no broadcast: nothing is emitted for nobody, and subscribers state their interest explicitly. notified::State gives services the monitor-a-value pattern out of the box.

Why do proxy methods take &mut self? How do I make concurrent calls?

Because a Connection is exclusively owned — mutation via &mut instead of internal locks is a core design decision (exterior mutability). A single connection is a strictly ordered request/reply pipeline, so concurrent calls over one connection wouldn’t buy what you might hope anyway.

To make concurrent calls, do the Varlink-idiomatic thing: open one connection per concurrent conversation. Connections are cheap — this is not the D-Bus world where connections are a scarce resource. For many calls to the same service, consider pipelining them over one connection instead: often faster than true concurrency, with none of the coordination.

Why can’t I tokio::spawn(server.run())?

A rustc bug prevents the future returned by Server::run from being Send. Until that’s fixed, drive the server with select! alongside your other work, or give it a LocalSet/LocalRuntime (or a plain dedicated thread). See the service chapter.

How do I use Option<T>?

Just use it. Varlink has nullable types (?type in the IDL), and serde’s Option<T> handling maps straight onto them — no option-as-array workarounds or sentinel values as in the D-Bus world.

Can I pass file descriptors?

Yes — over Unix domain sockets, methods can send and receive OwnedFds alongside the JSON payload (see the client chapter and the fds parameters throughout the low-level API). Note that the Varlink IDL has no file-descriptor type, so the convention is for JSON parameters to carry indexes into the accompanying descriptor array. This zlink extension interoperates with systemd’s use of the same convention; it requires std.

Why doesn’t my proxy method get a chain_ variant?

Chain methods are only generated for methods whose reply and error types are owned (DeserializeOwned). Borrowed reply types can’t be used in chains because the read buffer is reused from one reply to the next — see the pipelining chapter. Input parameters may borrow either way.

How do I try this against real services?

Recent systemd versions ship a growing set of Varlink services. Look around in /run for sockets named like interfaces (e.g. /run/systemd/resolve/io.systemd.Resolve, /run/systemd/io.systemd.Hostname), point varlinkctl at them, and then do the same from Rust with a proxy. The resolved and varlink-inspect examples in the repository are ready-made starting points.

Is there a blocking (non-async) API?

No. zlink is async-first by design — IPC is I/O-bound, and the APIs (streaming replies, pipelining, serving many connections from one task) lean heavily on async. If you’re in a synchronous program, you can wrap calls with your runtime’s block_on (e.g. tokio’s current-thread runtime or smol::block_on).