- Zig 94.5%
- Nix 4.1%
- Shell 1.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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,GetAllandSet— 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.Introspectableanswers 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.PeeranswersPingandGetMachineId.
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 whatpropertiesChangedsends for it.org.freedesktop.DBus.Method.NoReplymakes the client send the call without waiting for a reply. Such a method may not have results.org.freedesktop.DBus.Deprecatedmarks 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.
- Pennington, Havoc, Anders Carlsson, Alexander Larsson, Sven Herzberg, Simon McVittie, and David Zeuthen. D-Bus Specification. freedesktop.org. https://dbus.freedesktop.org/doc/dbus-specification.html.