cosmix-dbusd — the D-Bus boundary daemon
cosmix-dbusd is the one cosmix process that speaks D-Bus. Per the
ADR D-Bus is a foreign protocol — only cosmix-dbusd adapters speak it
(2026-09-18), every other cosmix app and daemon talks ABP on the Bus; the
D-Bus world is reached — in both directions — through per-domain adapters
hosted here, the same way IMAP and HTTP live at the edge.
What it does
- Hosts one adapter per D-Bus domain. An adapter owns exactly one Bus
service (registered under its domain name:
notify,tray, …) and one zbus connection for its whole run — both locals of itsrun, so an adapter that returns, fails or panics drops them and withdraws its names. Fault containment falls out of ownership: one adapter's death never touches another adapter or the daemon. One language limit: a panic insideDropduring a panic unwind aborts the whole process — containment covers one fault, not a second one during cleanup. - Supervises every adapter in its own tokio task, observing the
JoinHandle: a panic (
JoinError::is_panic), anErrreturn, or an unexpectedOkreturn all record a failure, put the adapter inbackoff, and relaunch it after an exponential schedule (1 s doubling to a 60 s cap; five healthy minutes reset the escalation). A consistently failing adapter stays inbackoffforever — it never takes the daemon down. A run that never yields cannot be preempted: it is reportedstuck, its Bus service and D-Bus names may stay held until the process restarts, and its lifecycle keeps answering commands. - Dials the desktop session bus from
DBUS_SESSION_BUS_ADDRESSper adapter. Unset or unreachable means that adapter sits inbackoffwith that reason; the daemon stays up and keeps answeringdbusd.*. - Serves the
dbusdBus control service (mesh-open; no caller authorization on any verb):dbusd.adapters— every adapter's{name, service, state, restarts, leaked_runs, last_error, since}and the session-bus endpoint state.dbusd.adapter.restart {name}/dbusd.adapter.enable {name}/dbusd.adapter.disable {name}— disable stops the adapter and releases its names (how a human hands, say, the Notifications name back to another daemon); enable and restart relaunch it. Unknown names get a refusal reply, never a panic.dbusd.props.{get,list,describe,watch}— the uniform props surface overdbusd.adapters.<name>.state|restarts|leaked_runs|last_error.dbusd.ping,dbusd.info.
- Publishes state changes as they happen — one
dbusd.adapter.changedevent plusdbusd.props.changeddiffs per transition, both stamped with a per-daemon-session monotonicevent_seq. Events can be dropped under backlog: a gap inevent_seqsays so, and the props (dbusd.props.get) are the truth. No polling anywhere: supervision is JoinHandle- and signal-driven.
Configuration is ~/.config/cosmix/dbusd.conf.mix
(enabled: ["notify", "tray"]); an absent enabled means every
built-in adapter, an empty list means none. An absent file materialises
the defaults on disk; a file that exists but cannot be read or parsed
is fatal — the daemon exits rather than silently guessing the adapter
set. Unknown names are logged and ignored. Enable/disable verbs are
runtime-only — the config file is the persistent source.
Adapter status
| state | meaning |
|---|---|
starting | launched; has not yet reported serving |
running | serving its domain |
backoff | failed (error/panic/exit); waiting out the schedule |
disabled | stopped by config or verb; names released |
stuck | the run did not stop within the abort window — it may be CPU-bound or never yielding; its names may stay held until the process restarts |
restarts counts every relaunch in this daemon process (backoff
restarts, the restart verb, enable-after-disable). last_error is the
most recent failure reason, cleared when the adapter runs healthy
again. leaked_runs counts runs detached as stuck in this daemon
process and never resets: the next healthy run clears last_error,
but the leaked spinner may still be holding names and burning a
worker — the count is its visible trace.
Running it
/opt/cosmix/bin/cosmix-dbusd serve
The supplied cosmix-dbusd.service is a systemd user unit: the Bus
citizen belongs to the graphical session, and the adapters dial the
desktop session bus (unix:path=/run/cosmix-desk-dbus/bus, set
explicitly because systemd user units do not inherit the session
environment). SIGTERM stops every adapter (releasing its D-Bus names)
and exits 0.
Writing an adapter
An adapter implements the Adapter trait — name(), bus_service(),
and async run(ctx) — and registers as one line in
citizen::builtin_adapters():
adapter_spec::<NotifyAdapter>(),
The context carries the session-bus endpoint, a per-launch stop signal,
a ready channel, and (under the cosmix feature) helpers that open the
adapter's own Bus connection and its own zbus connection. Keeping both
as locals of run is the whole containment story. The crate's core
(supervisor, backoff, registry, config) builds without the cosmix
feature and is unit-tested with a scripted fault adapter compiled only
under cfg(test).
Two adapters ship: notify and tray (sections below). Next come
settings, then folding mprisd (media) and powerd (power).
The notify adapter
Inbound org.freedesktop.Notifications (spec 1.2) on the session bus,
bridged to the notify Bus service — how foreign apps' notifications
become legible on the Bus, and how any mesh node posts to this
desktop.
Lifetime. The D-Bus server, the notification state and the session
connection survive a Bus outage: the Bus client is reconnected inside
the run (the dbusd citizen's reconnect doctrine, 60 s between
attempts) and the props-diff baseline survives the outage, so no live
notification is lost and org.freedesktop.Notifications is never
released for an activatable daemon to grab. A failed event publish
faults only the Bus session it failed on — publish faults carry a
session generation, and a stale fault from a superseded session is
ignored — and faults are acted on between commands, never
mid-dispatch; the publisher task is watched, and its death ends the
run with a clear error. The run ends — and the
supervisor backs off and re-dials — only when the session bus dies,
the adapter is stopped, or an internal fault makes progress
impossible.
D-Bus side. Owns org.freedesktop.Notifications at
/org/freedesktop/Notifications. If the name is already owned the run
fails with a clear error — never a replacement: the supervisor backs
off and retries, and a human uses dbusd.adapter.disable/enable (or
stops the other daemon) to hand the name over. Methods: Notify
(with replaces_id semantics — a live id is reused and replaced; a
non-zero id that is not live is created under that very id, and the
adoption reserves that id against the id clock, so the clock never
hands it to another app),
CloseNotification (→ NotificationClosed reason 3; an unknown id is
an error reply per spec 1.2 — the caller may be acting on stale
state), GetCapabilities (actions, body — only what is
implemented; persistence is not claimed), GetServerInformation
(cosmix, cosmix, crate version, 1.2). Signals:
NotificationClosed(id, reason) (1 expired, 2 dismissed, 3 closed by
call) and ActionInvoked(id, action_key). Notification ids come from
a process-wide clock seeded from the wall clock: never 0, never
re-issued by the clock (on the u32 wrap it continues from 1, still
skipping live ids), and fresh across adapter and daemon restarts —
one app's replaces_id/CloseNotification can never hit another
app's notification after a restart. An id repeats only when an app
explicitly adopts one via a non-live replaces_id. Expiry follows the pinned policy:
expire_timeout −1 means the server default — 8 s for low/normal
urgency, never for critical — and 0 means never; any finite expiry is
clamped to at least 1 s so a notification cannot close before the
client has received its id; every notification gets its own timer on
the monotonic clock (a wall-clock step can never delay or hasten it),
and a timer is cancelled the moment its notification is replaced,
closed or evicted. Timers are armed and cancelled under the same lock
as the state change (a concurrent replace can never leave a live
notification with the wrong timer — or none), and the expiry path
removes its own timer without aborting itself, so the closing
NotificationClosed signal is never lost to its own cleanup. Hints honoured: urgency (byte, plus the int32 /
uint32 forms non-conforming clients send), resident, transient,
desktop-entry, image-path; an image-data pixel payload is only
recorded as present (a n<id>.image_data prop) — raw pixels never
enter props.
Bus side (the notify service; mesh-open, no caller authorization
on any verb):
- Verbs:
notify.ping,notify.info,notify.list,notify.close {id}(→NotificationClosedreason 2),notify.invoke {id, action}(→ActionInvoked, then close with reason 2 unless theresidenthint is set), andnotify.send {summary, body?, app?, icon?, urgency?, timeout?, actions?, resident?, transient?, desktop_entry?, image_path?}— creates a notification exactly as ifNotifyhad been called (any mesh node may post to this desktop). Unknown ids, actions and verbs are refusal replies (rc 10), never panics. - Props:
notify.countand one subtree per live notification —notify.n<id>.{app,summary,body,icon,urgency,actions,expires_at, created_at,resident,transient,origin}plusdesktop_entry/image_path/image_datawhen present. The subtree vanishes when the notification closes.expires_atis null for never-expiring. - Events:
notify.changedcarriesnotification.created,notification.replaced,notification.closed(with reason code and name) andnotification.action_invoked;notify.props.changedcarries leaf diffs, and the run's first change publishes a diff too (the baseline is the empty tree). Events stamped under one state lock arrive as a batch; the batch's coalesced diff is stamped with the lastevent_seqit covers. Both topics carry a per-run monotonicevent_seq— a gap means events were dropped, andnotify.props.getis the truth.
Bounds. At most 256 live notifications and at most 16 MiB of stored notification text in total: any insert — a fresh one or a replace — that would pass either bound first expires the oldest notification (oldest by insertion order, not wall clock; non-critical before critical, but an all-critical set is capped too — critical buys priority, not unbounded growth). A replace counts only its size delta against the bytes bound and never evicts the record it replaces. Stored strings are capped (8 KiB, truncated at a char boundary and marked) and at most 32 actions are kept.
The tray adapter
Cosmix is the system-tray host. The tray adapter owns
org.kde.StatusNotifierWatcher at /StatusNotifierWatcher on the
desktop session bus and turns StatusNotifierItems into an ABP domain.
If the watcher name is already owned when the adapter starts, the run
fails with a clear error rather than replacing the owner — a human
hands the name over by dbusd.adapter.disable {name: "tray"} on this
side and enable once the other owner has released it.
D-Bus side (inbound, per the SNI convention):
RegisterStatusNotifierItemaccepts both forms: a bus name (item at/StatusNotifierItem) or an object path (item on the caller's own connection — resolved the way KDE/libappindicator do). Everything checkable synchronously is refused with a D-Bus error, never an OK reply followed by a silent drop:InvalidArgsfor a malformed name or path,AccessDeniedunless the caller owns the bus name it registers (one client cannot squat the tray with other apps' items),Failedfor an unowned name or a slow owner resolution (bounded at 2 s — the resolution runs in the interface on zbus's dispatch task, never in the run loop, so a flood of item signals cannot wedge the adapter behind it),LimitsExceededpast 64 items total or 8 items per registering connection. Re-registering an already-tracked(service, path)replaces it — always allowed, even at the caps. The run loop re-verifies on insert too: a registration whose owner already vanished (the resolution raced the client's disconnect) is refused — unique bus names are never reused, so nothing would ever reap such a ghost — and a loop-side capacity refusal emitsStatusNotifierItemUnregisteredfor the name the caller's OK reply promised.RegisterStatusNotifierHostrecords the host (cosmix itself already registered as host at startup, which is what makesIsStatusNotifierHostRegisteredtrue the whole time the adapter runs) and a first-time registration emitsStatusNotifierHostRegistered. Host registrations are capped at 64 and pruned when their connection vanishes.ProtocolVersionis 0.- Item identity is the
(service, path)pair: one connection may host several path-form indicators, each tracked and advertised separately.RegisteredStatusNotifierItems— and theStatusNotifierItemRegistered/StatusNotifierItemUnregisteredsignals — carryservice + pathfor every item (e.g.org.kde.StatusNotifierItem-1/StatusNotifierItemor:1.23/org/ayatana/NotificationItem/nm_applet), matching KDE's watcher. - Item lifetime is tracked by
NameOwnerChanged: an item goes when its registered bus name is released or moves to another owner, or when its connection itself vanishes (a unique name losing its owner). A connection releasing an unrelated name (MPRIS, anything) reaps nothing. No polling anywhere. - Item properties (
Id,Title,Category,Status,IconName,IconThemePath,AttentionIconName,ToolTip,Menu,ItemIsMenu,IconPixmap) are read once at registration and refreshed on the item'sNew*signals, one typedGetper property — neverGetAll: every reply body is checked against a raw-size cap before any deserialization (64 KiB for string properties, 1.25 MiB for the pixmap-bearingIconPixmap/ToolTipreplies, 1 MiB for menu layouts, 4 MiB for the rest — zbus itself accepts messages up to 128 MiB), and the pixmap-bearing replies deserialize straight to their typed wire shapes (a(iiay)tuples, flat byte vectors), never a per-byte value tree. An over-cap reply is refused undecoded: the pixmap is dropped, the refusal counted intray.info'soversized_reads, and the daemon's memory never amplifies (an over-cap string turns the whole refresh Unreachable — the item keeps its last-known props). A pixmap entry only counts whendata.len() == width * height * 4(ARGB32) and fits 1 MiB.Statusdefaults toActivewhen an item does not implement it; aMenuof/NO_DBUSMENUmeans no menu. Refreshes of one item are coalesced to at most one per 250 ms, with at most 8 property fetches in flight across all items (aNewIconflood cannot loop the item's own property reads; the freshest signal still converges). Every D-Bus call to an item runs under a timeout (2 s verbs, 3 s menu reads and property refresh, 500 ms for the optionalAboutToShowpre-flight), proxy builds included, so a hung app surfaces as a refusal and never wedges the adapter.
ABP side — the tray Bus service (mesh-open; no caller authorization):
- Props
tray.i<n>.{id,title,category,status,icon_name,icon_theme_path, attention_icon_name,tooltip,has_menu,item_is_menu,service,path}plustray.count. Keysi<n>are ordinals, stable for an item's lifetime and never reused; the leaves vanish with the item.tooltipis the ToolTip title (the description ridestray.list); the full per-item detail (tooltip text, menu path, pixmap size) is intray.list. IconPixmappixels never ride the props:tray.i<n>.pixmap_widthandpixmap_heightrecord the largest available size, and thetray.icon {id}verb serves the pixels as{width, height, encoding: "argb32-network-order", argb_b64}— the raw SNI bytes (ARGB32, network byte order, row-major, no padding), base64-encoded.- Verbs dispatch concurrently (bounded at 8 in flight): an item
hanging on its 2-3 s timeout delays only its own verb, never
tray.listortray.props.getbehind it. - Verbs:
tray.list;tray.activate {id, x?, y?},tray.secondary_activate,tray.context_menu,tray.scroll {id, delta, orientation}(horizontal|vertical);tray.icon {id};tray.menu {id}→ the item's com.canonical.dbusmenu layout —AboutToShow(0)first (the dbusmenu hook for lazily-built submenus), thenGetLayout(0, -1, propertyNames)with the names pinned to the small set the adapter surfaces,icon-dataexcluded — as a JSON tree of{id, label, enabled, visible, type, toggle_type, toggle_state, truncated, children}nodes (absentenabled/visibleare the dbusmenu defaulttrue; the tree is capped at 512 nodes and a cut anywhere in it carries"truncated": trueon the cut node AND every ancestor up to the root);tray.menu.click {id, item}→ dbusmenuEvent(item, "clicked")with the spec's(i s v u)body — a single-variant data (an int32), a u32 timestamp. Unknown ids and bad arguments are refusals (rc 10), never panics. - Events
tray.item.added/tray.item.changed/tray.item.removedplustray.props.changeddiffs, each stamped with a per-adapter-run monotonicevent_seq— one shape everywhere: a JSON number in verb and event bodies, a decimal string inBusMessageheaders. A gap means events were dropped; re-readtray.props.get.tray.infocarries the current counter, the host list, theoversized_readsrefusal count and a bounded ring of recent events. - A Bus (mesh) outage never ends the run: the broker reconnects the
trayBus client inside the run and the publisher keeps its diff baseline, so the next event re-diffs the whole outage window and items survive. Only the stop signal, session-bus death (observed on the zbus connection's closed signal), the Bus-side broker or publisher task dying, or a real internal fault ends a run.
Bounds: 64 items (8 per registering connection), 64 hosts, 4096 chars
per string, 1 MiB per stored pixmap (largest wins, w*h*4 bytes
exactly), raw reply caps before any deserialization (64 KiB strings,
1.25 MiB pixmap-bearing, 1 MiB menu layouts, 4 MiB general), 512 menu
nodes per layout read, one refresh per item per 250 ms, 8 property
fetches in flight across all items.