D-Bus services in Zig on the mdbus transport: objects, interfaces, properties and signals, declared as data.
  • Zig 94.5%
  • Nix 4.1%
  • Shell 1.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 0b127fb997
All checks were successful
test / test (push) Successful in 21m11s
test / docs (push) Successful in 10m50s
Queueing frees what it made once, when an allocation fails
When appending to the outbox failed, queue freed the item and returned the
error, and the errdefers in emitSignal and propertiesChanged then freed the
same strings again: a service out of memory while sending a signal crashed
on a double free. queue now leaves the item to its caller.

Found by zig-mpris's fuzz seeds replayed with each allocation failing in
turn; the test here does the same for both ways in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFAKTW62t5J1qkcKDuZ8Gy
2026-10-06 00:30:01 -05:00
.forgejo/workflows CI: Zig dependencies through Nix, and what was built pushed to the cache 2026-10-05 17:41:58 -05:00
LICENSES README: the Radicle ID 2026-09-24 15:32:42 -05:00
src Queueing frees what it made once, when an allocation fails 2026-10-06 00:30:01 -05:00
tests Generate typed servers and clients from introspection XML 2026-10-05 19:47:45 -05:00
tools Generate typed servers and clients from introspection XML 2026-10-05 19:47:45 -05:00
.gitignore Fuzzing: method calls from any peer, dispatched and answered 2026-09-26 17:46:32 -05:00
build.zig Generate typed servers and clients from introspection XML 2026-10-05 19:47:45 -05:00
build.zig.zon Generate typed servers and clients from introspection XML 2026-10-05 19:47:45 -05:00
build.zig.zon.nix Generate typed servers and clients from introspection XML 2026-10-05 19:47:45 -05:00
flake.lock Zig 0.17 2026-10-05 17:18:51 -05:00
flake.nix Zig 0.17 2026-10-05 17:18:51 -05:00
package.nix Generate typed servers and clients from introspection XML 2026-10-05 19:47:45 -05:00
README.md Generate typed servers and clients from introspection XML 2026-10-05 19:47:45 -05:00
REUSE.toml README: the Radicle ID 2026-09-24 15:32:42 -05:00

zig-dbus-service

D-Bus services in Zig, on the mdbus transport.

mdbus is a client transport and deliberately no more: it sends and receives messages and has no idea what an object is. This is the rest of what a service needs — objects at paths, interfaces with methods, properties and signals, and a name on the bus — declared as data. Every question a peer can ask is answered from the declarations:

  • a method call is routed by path, interface and member, its arguments checked against the declared signature before the handler sees them, and answered with the handler's results or a D-Bus error of the right name;
  • org.freedesktop.DBus.Properties — Get, GetAll and Set — through each property's getter and setter, with a write of the wrong type refused before the setter sees it and a read-only property refused outright;
  • org.freedesktop.DBus.Introspectable answers with XML describing the object's interfaces, and a node for each step down towards the objects below any path, so a peer starting at / finds everything;
  • org.freedesktop.DBus.Peer answers Ping and GetMachineId.

The declarations can be written by hand, or generated at build time from an interface's introspection XML by dbus-codegen. Generated bindings give each method's arguments and results a Zig type, so a handler is an ordinary function, and they give a typed client for calling the same interface on another service.

It was written for Pipit's MPRIS server, and is not specific to it.

Using it

This is the hand-written form; generating them is usually less work.

const dbus = @import("dbus_service");

const player: dbus.Interface = .{
    .name = "org.example.Player",
    .methods = &.{.{
        .name = "Add",
        .in = &.{.{ .name = "amount", .signature = "i" }},
        .out = &.{.{ .name = "count", .signature = "i" }},
        .handler = add,
    }},
    .properties = &.{.{
        .name = "Count",
        .signature = "i",
        .access = .readwrite,
        .get = getCount,
        .set = setCount,
    }},
    .signals = &.{.{ .name = "Changed", .args = &.{.{ .signature = "i" }} }},
};
const interfaces = [_]dbus.Object.Implementation{
    .{ .interface = &player, .context = &state },
};
const objects = [_]dbus.Object{.{
    .path = "/org/example/Player",
    .interfaces = &interfaces,
}};

var connection = try mdbus.Connection.connectSession(io, gpa, environ);
var service = try dbus.Service.init(gpa, io, &connection, &objects);
try service.requestName("org.example.Player");
try service.run(); // on a thread of its own, until service.stop()

A handler reads its arguments from call.args and writes its results to call.reply; call.arena is for anything needed only until the reply is sent. A getter writes a value, a setter reads one. Each interface on an object has its own context, which is the pointer its handlers are given, so one object can be several interfaces implemented by several types.

The connection belongs to the service's thread while run runs. An mdbus connection is used from one thread at a time, so everything the rest of the program wants sent — emitSignal, propertiesChanged — is queued, safely from any thread, and wakes run through an eventfd to send it. propertiesChanged queues only the names of what changed; the values are read through the getters when the signal goes out, so a peer is told what it would read with Get at that moment, and the getters only ever run on the service's thread. They have to be safe to call from it, which for a program with state shared between threads means taking its own lock.

tools/example.zig is a complete service — a counter with a method, a read-write property and a signal, served through generated bindings — and the documentation is generated from the doc comments: https://jeff.jcollie.page/zig-dbus-service/.

Generating code from introspection XML

dbus-codegen reads D-Bus introspection files — the XML format Introspect answers with, which the D-Bus Specification describes under "Introspection Data Format" — and writes Zig bindings for every interface in them. It is what gdbus-codegen is to GLib. The library ships no interface files of its own: the files are yours, and the build generates the bindings from them, so editing one regenerates them.

In build.zig, with this package as a dependency:

const dbus_service = b.dependency("dbus_service", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("dbus_service", dbus_service.module("dbus_service"));
exe.root_module.addImport("dbus_interfaces", @import("dbus_service").addInterfaces(b, dbus_service, &.{
    b.path("dbus/org.example.Counter.xml"),
}));

Each interface becomes a namespace named after the last part of its name, so org.example.Counter is @import("dbus_interfaces").Counter. It declares a struct per method (with In and Out), per signal (with Args) and per property (with Type), an emit function per signal, and Server and Client. Argument names become snake_case fields; an unnamed argument is arg0, arg1 and so on, by position.

Serving it

Server(Impl) turns a type implementing the interface into the Interface declaration the service serves, and checks at compile time that Impl has everything the interface needs:

const Counter = @import("dbus_interfaces").Counter;

const State = struct {
    count: i32 = 0,

    // A handler per method: the method's name, first letter lowered.
    pub fn add(self: *State, _: *dbus.Call, in: Counter.Add.In) !Counter.Add.Out {
        self.count += in.amount;
        return .{ .count = self.count };
    }

    // A getter per readable property, and a setter per writable one.
    pub fn getCount(self: *State, _: std.mem.Allocator) !i32 {
        return self.count;
    }
    pub fn setCount(self: *State, value: i32) !void {
        self.count = value;
    }
};

var state: State = .{};
const interfaces = [_]dbus.Object.Implementation{Counter.Server(State).implementation(&state)};
const objects = [_]dbus.Object{.{ .path = "/org/example/Counter", .interfaces = &interfaces }};

// From any thread:
try Counter.emitChanged(&service, "/org/example/Counter", .{ .count = state.count });
try service.propertiesChanged("/org/example/Counter", Counter.name, &.{Counter.Count.name});

A handler returns its results, or void for a method that has none. To refuse a call it returns an error: error.InvalidArgs and error.NotSupported are the D-Bus errors of those names, call.fail(name, message) names any other, and anything else is org.freedesktop.DBus.Error.Failed. Arguments that do not match the method's signature are refused before the handler is called.

What a handler or setter is given lasts only as long as the call: strings and byte arrays point into the message, and arrays are in call.arena. Anything kept has to be copied.

Calling it

Client calls the same interface on another service, over an mdbus connection of its own: the Service's connection belongs to its thread.

var counter: Counter.Client = .{ .connection = &connection, .destination = "org.example", .path = "/org/example/Counter" };

const out = try counter.call(Counter.Add, arena, .{ .amount = 5 });
const count = try counter.get(Counter.Count, arena);
try counter.set(Counter.Count, arena, 0);

var buf: [256]u8 = undefined;
try connection.addMatch(try counter.matchRule(Counter.Changed, &buf));
while (try connection.nextMessage()) |message| {
    var m = message;
    defer m.deinit();
    if (try counter.signal(Counter.Changed, &m, arena)) |args| handle(args.count);
}

Calls block until the reply comes, or timeout_ms passes. A reply is decoded into the arena it is given, so it lasts as long as the arena does. An error reply is error.RemoteError, and counter.failure holds the D-Bus error's name and message. tools/client_example.zig is a complete client of the example service.

Types

D-Bus Zig
y b n q i u x t d u8 bool i16 u16 i32 u32 i64 u64 f64
s, ay []const u8
o dbus.ObjectPath
g dbus.Signature
h dbus.UnixFd: the index of a descriptor in call.message.fds
v dbus.Variant: Variant.of("d", &value) to send one, variant.get("d", f64, arena) to read one
a{sv} dbus.Vardict: vardict.get("s", []const u8, "key", arena)
a{KV} []const dbus.DictEntry(K, V)
aT []const T
(T…) a tuple, struct { T, … }

dbus.codec does the conversion, and works without generated bindings: codec.encode("a(su)", T, value, &encoder) writes any value of a type that fits the signature, and anything that does not fit is a compile error.

Annotations

  • org.freedesktop.DBus.Property.EmitsChangedSignal, on a property or on its interface as the default, decides what propertiesChanged sends for it.
  • org.freedesktop.DBus.Method.NoReply makes the client send the call without waiting for a reply. Such a method may not have results.
  • org.freedesktop.DBus.Deprecated marks the declaration deprecated in its doc comment.

Every annotation, these and any others, is kept and appears in what Introspect answers with.

What it does not do

  • A file descriptor is only an index. The server can find the descriptor in call.message.fds, but a reply cannot carry one, and neither can a client's call.
  • There are no maybe types (m), which are GVariant's and not D-Bus's.
  • A client's calls are blocking, as mdbus's are.

Building and testing

main builds with Zig 0.17. Zig 0.16 is served by the zig-0.16 branch, which starts at the v0.1.0 tag and takes only critical fixes. The Nix flake's devshell provides the right Zig, D-Bus and the REUSE tooling.

git clone https://git.jcollie.dev/jeff/zig-dbus-service.git
cd zig-dbus-service
nix develop -c zig build test --summary all
nix develop -c zig build && nix develop -c dbus-run-session -- bash tests/bus.sh

The unit tests cover the dispatcher by building the method calls it would receive and reading what it answers, with no socket. tests/generated.zig does the same through bindings generated from tests/fixtures/org.example.Types.xml, which has every type and annotation, then reads the object's introspection back with the generator's own parser and checks that it describes exactly what the file did. tests/bus.sh does what they cannot: it runs the example service on a private session bus and drives it with gdbus, GLib's own implementation — calls, properties, introspection, errors, and the signals heard by a monitor — and then with the generated client. All of them run in CI.

The generator parses XML with zxml, which only the build itself needs: nothing it generates depends on it.

Fuzzing

Anything on the bus may call a service, so Dispatcher.dispatch is fuzzed with calls nobody wrote: any path, interface, member and signature, and arguments of any values that fit the signature, made by tests/fuzz.zig from the fuzz input and encoded and checked by mdbus as the bus would deliver them. Whatever arrives, it has to answer, free what it allocated, and answer with something that can be sent: a return value whose body is exactly what its signature says, or an error with a valid name and a description that is text. The seeds, made by tools/fuzz_corpus.zig, call every method and property of a set of objects covering most of D-Bus's types, and the standard interfaces; zig build test checks the property on them.

There are two ways to fuzz it. Zig's own fuzzer steers by coverage, and reports a failing input as input saved to '.zig-cache/f/crash':

$ zig build fuzz --fuzz          # until interrupted, with a web interface
$ zig build fuzz --fuzz=1M       # a bounded run, then a report

fuzz-run is a loop of this project's own in tools/fuzz.zig, which mutates the seeds without coverage feedback, can be run for a fixed time or from a fixed seed, and writes a failing input to fuzz-findings/, where --input runs it again:

$ zig build fuzz-run                                # a minute
$ zig build fuzz-run -- --seconds 300
$ zig build fuzz-run -Dfuzz-optimize=debug -- --input fuzz-findings/x.bin

Where this lives

The repository has three homes, and they hold the same history.

Where How to get it
Forgejo git clone https://git.jcollie.dev/jeff/zig-dbus-service.git
Tangled git clone https://tangled.org/jcollie.dev/zig-dbus-service
Radicle rad clone rad:z2edyp2i3WjfQrgauZqyM1vmxZ9dK

A Radicle repository is findable only by its ID, so that one is written out in full: rad:z2edyp2i3WjfQrgauZqyM1vmxZ9dK.

Licensing

MIT, following the REUSE specification; reuse lint checks it.

References cited

Kept in the zig-dbus-service Zotero collection.