Monstar DBUS - A small D-Bus client transport for Zig.
  • Zig 97.5%
  • Nix 2.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 624d9a384c
All checks were successful
test / reuse (push) Successful in 1m23s
test / test (push) Successful in 8m23s
test / vm (push) Successful in 7m49s
test / docs (push) Successful in 6m51s
Signals can be addressed to one peer
`Signal` gains an optional `destination`. With it set, the bus delivers
the signal to that peer alone rather than to every matching connection,
which is how xdg-desktop-portal answers a request with
`Request::Response`, so a test standing in for a service can now answer
the same way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EK4fLKrwb19eaj7EE615CV
2026-10-14 18:40:17 -05:00
.forgejo/workflows CI: jobs that boot virtual machines run on the m tier 2026-09-26 21:30:52 -05:00
examples Zig 0.17 2026-10-05 17:01:39 -05:00
LICENSES license flake.lock under CC0-1.0 2026-08-27 23:43:03 -05:00
src Signals can be addressed to one peer 2026-10-14 18:40:17 -05:00
tools Zig 0.17 2026-10-05 17:01:39 -05:00
.gitignore Zig 0.17 2026-10-05 17:01:39 -05:00
build.zig Zig 0.17 2026-10-05 17:01:39 -05:00
build.zig.zon Zig 0.17 2026-10-05 17:01:39 -05:00
flake.lock Zig 0.17 2026-10-05 17:01:39 -05:00
flake.nix Zig 0.17 2026-10-05 17:01:39 -05:00
README.md Signals can be addressed to one peer 2026-10-14 18:40:17 -05:00
REUSE.toml license flake.lock under CC0-1.0 2026-08-27 23:43:03 -05:00
typos.toml add wire format tests from the reference suite 2026-08-28 00:01:34 -05:00

mdbus

Monstar DBUS - A small D-Bus client transport for Zig.

mdbus speaks enough of the D-Bus protocol to talk to services on the session bus: it authenticates over a Unix-domain socket, encodes and decodes the wire format, sends method calls and signals, correlates replies with their calls, and passes Unix file descriptors. It is deliberately not a general D-Bus binding — there is no object model, no introspection, and no code generation. Service-specific message construction belongs in the module that owns it.

This library was originally part of Monstar.

The API reference is generated from the module's doc comments and published at https://jeff.jcollie.page/mdbus/.

Requirements

  • Zig 0.17
  • Linux
  • A session bus reachable over a Unix socket (DBUS_SESSION_BUS_ADDRESS, or $XDG_RUNTIME_DIR/bus as a fallback)

Where this lives

The canonical repository is on my Forgejo instance, and Codeberg and Tangled carry mirrors of it:

git clone https://git.jcollie.dev/jeff/mdbus.git
git clone https://codeberg.org/jcollie/mdbus.git
git clone https://tangled.org/jcollie.dev/mdbus

It is also on Radicle as rad:z3EbByAjuCYv287zy8crpoXiaXszv, which is the only name that finds it there — a peer-to-peer repository has no host to browse:

rad clone rad:z3EbByAjuCYv287zy8crpoXiaXszv

All four serve the same history. CI, the published documentation and the issue tracker follow the canonical one.

Installation

Add the dependency to your build.zig.zon:

zig fetch --save https://git.jcollie.dev/jeff/mdbus/archive/main.tar.gz

main targets 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:

zig fetch --save https://git.jcollie.dev/jeff/mdbus/archive/zig-0.16.tar.gz

Then wire the module into your build.zig:

const mdbus = b.dependency("mdbus", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("mdbus", mdbus.module("mdbus"));

Message size limit

mdbus refuses to encode or accept a message larger than 16 MiB by default. The limit is what bounds how much a hostile peer can make the library allocate, so it is deliberately well under the 128 MiB the protocol permits. A build that expects larger messages — or that wants a tighter bound than the default — sets max_message_size:

const mdbus = b.dependency("mdbus", .{
    .target = target,
    .optimize = optimize,
    .max_message_size = @as(usize, 64 * 1024 * 1024),
});

A value above the protocol maximum, or below a message's 16-byte fixed header, fails the build rather than being clamped silently. The value in effect is readable as mdbus.max_message_size, and the protocol's own ceiling as mdbus.spec_max_message_size. Raise it only as far as the messages actually expected: a connection queues up to 256 messages, so the worst-case memory a peer can pin scales with this setting.

Usage

const std = @import("std");
const mdbus = @import("mdbus");

pub fn listNames(
    io: std.Io,
    allocator: std.mem.Allocator,
    environ: std.process.Environ,
) !void {
    var connection = try mdbus.Connection.connectSession(io, allocator, environ);
    defer connection.deinit();

    var reply = try connection.call(.{
        .destination = "org.freedesktop.DBus",
        .path = "/org/freedesktop/DBus",
        .interface = "org.freedesktop.DBus",
        .member = "ListNames",
    }, "", &.{}, &.{}, 5000);
    defer reply.deinit();

    if (reply.messageType() == .error_reply) return error.RemoteError;

    var decoder = reply.bodyDecoder();
    const end = try decoder.beginArray('s');
    while (!try decoder.arrayFinished(end)) {
        const name = try decoder.string();
        std.debug.print("{s}\n", .{name});
    }
    try decoder.endArray(end);
}

Arguments are built with an Encoder and passed alongside their D-Bus signature. To send a file descriptor, encode its index into the fds slice:

var body: mdbus.Connection.Encoder = .init(allocator);
defer body.deinit();
try body.unixFd(0);

var reply = try connection.call(.{
    .destination = "com.example.Service",
    .path = "/com/example/Service",
    .interface = "com.example.Service",
    .member = "TakeFd",
}, "h", body.bytes(), &.{fd}, 1000);
defer reply.deinit();

Complete programs live under examples, each its own package consuming mdbus as a path dependency the way an out-of-tree caller would: open-uri opens a URL through the desktop portal and waits for the portal's Response signal, open-file does the same for a local file, which the portal takes as a passed file descriptor rather than a path, and open-directory reveals a file in the file manager the same way. From an example's directory:

zig build run -- https://example.org

Encoding a body

A body is built value by value with an Encoder, then handed to call, sendSignal, or sendReply together with the signature describing it. The encoder does not track the signature — you state it once, at the send, and write values that match it. What the encoder does track is alignment: every value is padded to the boundary its type requires, computed against the message as a whole, which is the part of the wire format worth outsourcing.

Scalars and strings are single calls, written in signature order. This body matches the signature sub:

var body: mdbus.Connection.Encoder = .init(allocator);
defer body.deinit();

try body.string("backlight");
try body.uint32(40);
try body.boolean(true);

An array is bracketed by beginArray and endArray. The wire format prefixes an array with its byte length, which is not known until the elements have been written, so beginArray leaves a hole and returns a bookmark, and endArray patches the length in. beginArray takes the character the element type's signature begins with — 'i' for ai, '(' for a(us), '{' for a{sv} — and derives the element alignment from it. A struct's fields are preceded by structAlignment, which pads to the eight-byte boundary every struct starts on. Together, for a(us):

const entries = try body.beginArray('(');
for (devices) |device| {
    try body.structAlignment();
    try body.uint32(device.id);
    try body.string(device.name);
}
try body.endArray(entries);

A dictionary is an array of dict entries, and an entry is a key, a value, and the same eight-byte alignment a struct gets — dictEntryAlignment, named for what it precedes. The common a{sv} pairs string keys with variants, where a variant is a signature naming exactly one type followed by a value of that type:

const properties = try body.beginArray('{');

try body.dictEntryAlignment();
try body.string("Volume");
try body.variantSignature("d");
try body.double(0.5);

try body.dictEntryAlignment();
try body.string("Muted");
try body.variantSignature("b");
try body.boolean(false);

try body.endArray(properties);

The content of a byte array moves in bulk rather than a call per byte, so ay carrying real data is three calls regardless of its size:

const blob = try body.beginArray('y');
try body.byteSlice(image_data);
try body.endArray(blob);

Sending is where the signature is stated and checked — the encoder trusts you until then, and the values written must match it:

var reply = try connection.call(.{
    .destination = "com.example.Mixer",
    .path = "/com/example/Mixer",
    .interface = "com.example.Mixer",
    .member = "Configure",
}, "suba(us)a{sv}ay", body.bytes(), &.{}, 5000);
defer reply.deinit();

The connection sends a body as a gathered write: the header and your body bytes go to the socket as two vectors of one sendmsg, so a large body — a megabyte of ay, say — is handed to the kernel directly rather than copied into a full-message buffer first.

Reusing encoding memory

The allocator handed to Encoder.init is where the body's bytes accumulate, and the encoder does not care where that memory comes from. A caller assembling many messages can hand it a std.heap.FixedBufferAllocator over one long-lived buffer, or an ArenaAllocator it resets between messages, which turns per-message heap traffic into reuse of the same warm memory — worth doing when profiling shows allocation churn, and not before.

var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();

for (readings) |reading| {
    // Reclaims every body at once and keeps the buffer for the next one,
    // which is also why no encoder deinit is needed here.
    _ = arena.reset(.retain_capacity);

    var body: mdbus.Connection.Encoder = .init(arena.allocator());
    try body.string(reading.name);
    try body.double(reading.value);

    try connection.sendSignal(.{
        .path = "/com/example/Sensor",
        .interface = "com.example.Sensor",
        .member = "Reading",
    }, "sd", body.bytes());
}

The allocator given to connectSession is a different matter: the connection allocates every received message from it, and each lives until its deinit, so that one must be a real allocator that can free independently — never an arena being reset while messages are alive.

Decoding a body

Reading mirrors writing: Message.bodyDecoder returns a Decoder positioned at the start of the body, and values are read in the order the signature (Message.bodySignature) lists them. A message that came out of parseMessage — which is every message a Connection hands you — has already had its body validated against its signature, so decoding follows the signature without running off the end. Strings, paths, and byte slices alias the message's buffer rather than being copied, and are valid until the message's deinit.

An array read is bracketed the way an array write is: beginArray takes the same element character, returns the position where the array ends, and arrayFinished against that position drives the element loop. For the a(us) written above:

var decoder = reply.bodyDecoder();

const entries = try decoder.beginArray('(');
while (!try decoder.arrayFinished(entries)) {
    try decoder.structAlignment();
    const id = try decoder.uint32();
    const name = try decoder.string();
    std.debug.print("{d}: {s}\n", .{ id, name });
}
try decoder.endArray(entries);

A dictionary reads as an array of eight-aligned entries — structAlignment covers a dict entry too, since both pad to the same boundary. A variant's value is read according to the signature that opens it, and skipSignatureValue steps over a value of any type, which is what to do with a key you do not recognize:

const properties = try decoder.beginArray('{');
while (!try decoder.arrayFinished(properties)) {
    try decoder.structAlignment();
    const key = try decoder.string();
    const signature = try decoder.variantSignature();
    if (std.mem.eql(u8, key, "Volume") and std.mem.eql(u8, signature, "d")) {
        volume = try decoder.double();
    } else {
        try decoder.skipSignatureValue(signature);
    }
}
try decoder.endArray(properties);

A byte array comes back in bulk as a slice of the message, with the array's end position saying how much to take:

const blob = try decoder.beginArray('y');
const image_data = try decoder.byteSlice(blob);
try decoder.endArray(blob);

A received file descriptor travels as an h index into the message's descriptor array, which is resolved — after checking it, since indices are values a peer chose — against Message.fds:

const index = try decoder.unixFd();
if (index >= reply.fds.len) return error.InvalidMessage;
const fd = reply.fds[index];

Finishing with decoder.end() asserts the body held nothing beyond what was read, which catches a signature misunderstanding on the last value as surely as on the first.

API

The full API reference, rendered from the module's doc comments, is published at https://jeff.jcollie.page/mdbus/.

Connection is the entry point:

Function Purpose
connectSession Connect, authenticate, and say Hello to the bus
connectSystem The same, to the system bus
connectAddress The same, to a bus at a D-Bus server address
call Send a method call and wait for its reply
sendMethod Send a method call, returning its serial
sendSignal Emit a signal, broadcast or to one destination
sendReply Answer a received method call with its return value
sendError Answer a received method call with an error
addMatch Install a match rule and confirm the bus accepted it
waitForReply Wait for one serial, queueing unrelated messages
waitForMessage Wait for the first message a predicate accepts
takeMessage Take the first message a predicate accepts, non-blocking
nextMessage Take the next queued or received message, non-blocking
uniqueName The unique name the bus assigned in reply to Hello
getFd / hasQueuedMessages Integrate the connection into an event loop

A received method call carries the unique name of whoever sent it, which is what sendReply and sendError address their answer to, so answering one takes nothing beyond the message itself. Message.noReplyExpected reports whether the caller is waiting for that answer at all.

Messages own their memory and their received descriptors; call deinit on every message you are handed. Message.bodyDecoder returns a bounds-checked Decoder over the body, and Encoder builds bodies for outgoing messages. Both handle every type in the specification: bytes, booleans, signed and unsigned 16-, 32-, and 64-bit integers, doubles, Unix file descriptors, strings, object paths, and signatures, plus arrays, structs, dict entries, and variants. The content of a byte array moves in bulk through Encoder.byteSlice and Decoder.byteSlice, and an i array's through int32Slice, rather than a call per element. Neither tracks a signature of its own — the caller states the signature and writes values that match it — but Decoder.skipSignatureValue walks any value from its signature, and parseMessage uses it to validate an incoming body before returning it.

Because dispatch is non-blocking and driven by getFd, an application can poll the connection alongside its other file descriptors and drain messages with nextMessage as they arrive.

takeMessage and waitForMessage take a context value and a comptime predicate, fn (@TypeOf(context), *const Message) bool, and remove only the first message it accepts; everything else stays queued, in arrival order, for nextMessage. That lets a library wait for its own reply or signal on a connection the application shares without swallowing messages the application is expecting:

fn isFrom(sender: []const u8, message: *const mdbus.Connection.Message) bool {
    return std.mem.eql(u8, message.header.sender orelse "", sender);
}

var signal = try connection.waitForMessage(@as([]const u8, ":1.42"), isFrom, 5000);
defer signal.deinit();

Development

A Nix flake provides Zig 0.17 and the REUSE tooling:

nix develop
zig build test

The unit tests run without a bus. The live-bus tests in src/live_bus_test.zig skip themselves unless a session bus is available and MONSTAR_DBUS_INTEGRATION=1 is set:

MONSTAR_DBUS_INTEGRATION=1 zig build test

To run the whole suite against a bus that is known to be dbus-broker, without needing one on the development machine, the flake boots a NixOS virtual machine whose session bus is dbus-broker and runs the tests inside it:

nix build .#checks.x86_64-linux.dbus-broker

The check fails unless every test ran, so a live-bus test that skipped itself is treated as a failure rather than a pass. packages.<system>.test-runner builds the same test binary on its own, which is what the virtual machine installs and executes.

CI runs that check too, as nix flake check in a job of its own, on a runner able to boot the guest. The live-bus tests are therefore covered on every push rather than only when somebody remembers to run them by hand.

zig build docs renders the module's doc comments to zig-out/docs, and zig build docs-serve builds the same pages and serves them at http://127.0.0.1:8000/, with -Ddocs-port=N to choose another port. A server is needed rather than opening the files directly, because the viewer fetches its data at runtime and a browser refuses that from a file:// page; zig std works this way for the same reason. CI publishes the same pages to https://jeff.jcollie.page/mdbus/ on every push to main that passes the tests.

Licensing headers follow the REUSE specification; reuse lint checks them.

License

MIT. See LICENSES/MIT.txt.

The D-Bus client originated in Tim Culverhouse's Monstar project and is reused here under the same license; Tim is credited as the original author in every file header.

References cited

Kept in the mdbus 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. The wire format src/wire encodes and decodes, its limits, the message types, and the authentication protocol Connection.zig speaks.
  • Melnikov, A., ed., and K. Zeilenga, ed. Simple Authentication and Security Layer (SASL). RFC 4422. Internet Engineering Task Force (IETF), June 2006. https://www.rfc-editor.org/info/rfc4422. Appendix A's EXTERNAL mechanism, which is how a client proves who it is to a bus: by the credentials of the Unix socket it connected over.