Skip to main content

dbus-native roadmap

Written 2026-07-28, after the maintenance pass that migrated CI to GitHub Actions, pruned the dependency tree, and modernised the source.

This document triages the open issues and PRs and proposes what to do next. It is a plan of record, not a promise of dates.


0. The elephant in the room: positioning — resolved

#263 is answered: work has restarted here, and the package is not being deprecated. The rest of this section is the reasoning, kept because it is what the answer rests on.

Three packages serve the same users:

packagelatestpublisheddownloads/week
@homebridge/dbus-native (soft fork of this repo)0.7.82026-07-2537.1k
dbus-next (acrisci fork/rewrite)0.10.22022-04-2818.0k
dbus-native (this repo)0.15.02026-07-309.3k

Downloads are the week ending 2026-07-28 — before any of this pass shipped, so they measure the position that had to be argued out of, not the one it created.

Two things stood out:

  • The Homebridge fork is the de-facto maintained dbus-native. It is a light fork of this codebase (dropped abstract-socket, swapped optimist for minimist, forked long.js for an ARMv6 crash) and it has more downloads than this package and dbus-next combined. Its changes are a strict subset of what this pass did — this repo is down to one runtime dependency against their six.
  • #263 ("Deprecate dbus-native") was agreed in principle in 2019 and never executed. dbus-next was to take over the npm name; that never happened, and dbus-next has itself been dormant since 2022.

Decision: do not deprecate. This repo is the most-forked, most-depended-on of the three and is now the most modern. What followed from it:

  1. Reach out to the Homebridge maintainers about folding their fork back in. Outstanding, and the case is stronger than it was. All three of their deltas are addressed here now — abstract sockets come from Node, arguments from node:util.parseArgs, and the long.js ARMv6 workaround has nothing left to work around since the dependency was dropped in #374. Auditing their fork also turned up three real bugs here (#380, #382, #383), one of them a remotely-triggerable crash — so the exchange runs both ways.
  2. Close #263 with a note explaining the current state. Answered 2026-07-30 with the progress since 2019 and the decision to restart work here. Left open for comment rather than closed.
  3. Ship a 0.5.0 so the ecosystem sees a signal of life. Done, and then some — 0.5.0 through 0.15.0 between 2026-07-28 and 2026-07-30, ending with the value-shape flip this document's §3.2 and §4.1 were written for.

BIG_FUTURE_PLANS.md §7 argued that #263 should be answered before building anything, since every proposal in it is an argument that the answer is no. The building happened first. That did not change the answer, but it does mean the releases are the evidence for it rather than a promise of it.


1. Next release (0.5.0) — already done in this pass

Landed and verified; listed here so the changelog writes itself.

  • Travis → GitHub Actions; matrix over Node 22/24/26 on Linux and macOS (20/22/24 until 2.0, when Node 20 went end-of-life).
  • release-please with npm trusted publishing (OIDC) — no NPM_TOKEN, and provenance attached automatically.
  • Dependabot for npm and GitHub Actions.
  • Runtime dependencies cut from 7 (+1 optional native) to 3 (hexy, long, xml2js) — and to 2 in 0.11, when hexy went with lib/portforward.js:
    • abstract-socket removed — Node ≥ 20.8 supports Linux abstract sockets natively via a \0 path prefix. Fixes the whole class of native-build failures (#193).
    • put replaced by a 40-line lib/put.js (#271, #262).
    • optimist replaced by node:util.parseArgs (#286).
    • event-stream replaced by stream.Duplex.from — also removes a package with a notable supply-chain history.
    • safe-buffer removed; xml2js → 0.6.2 (#294).
  • varconst/let, arrow callbacks, template literals throughout.
  • Real integration tests against a live dbus-daemon (12 tests), plus npm run dbus:session for local development on macOS.
  • Bug fixes found along the way:
    • Properties.Set assigned the literal 1234 instead of the value sent (#129).
    • Peer.GetMachineId returned the English sentence "This is a machine id. TODO: implement".
    • getUserHome() matched on /$win/, a regex that can never match, so the DBUS_COOKIE_SHA1 keyring path was wrong on Windows.
    • if (!methods.empty)Array has no .empty, so the auth-fallback branch was dead code, and the failure path called back with a Buffer instead of an Error.
    • Placeholder 'Uh oh oh' D-Bus errors replaced with the correct UnknownObject/UnknownInterface/UnknownMethod/UnknownProperty names and useful messages (#39, #207, #208 are adjacent).
    • Two examples used `...${err}` ? err : '(no error)' — a template literal is always truthy, so the fallback never ran.

2. Wire layer: audit findings

The marshaller, unmarshaller and stream framing were audited on 2026-07-28 against commit 8373dcb. Every claim below was measured or reproduced, not inferred from reading; the numbers are from an M-series Mac on Node 26 and are meant for relative comparison, not as absolutes.

Blocks are sized to be one PR each and are listed in the order they should land. §2.1 is security-relevant and should go first; §2.2 is the largest win and is independent of it.

2.1 Harden the read loop against malformed input

Severity: high — a hostile or buggy peer can crash the process.

lib/message.js reads a 16-byte header, trusts the declared lengths, and calls onMessage() from inside the 'readable' handler with no error boundary. Reproduced, each in a fresh process:

bad-header exit=1 CRASHED: RangeError [ERR_OUT_OF_RANGE]: "size" ... <= 1GiB
handler-throws exit=1 CRASHED: Error: handler blew up
truncated-tail exit=0 survived (waiting for rest)

Work:

  • Enforce the spec limits. The D-Bus spec caps a message at 128 MiB and an array at 64 MiB. Nothing enforces either, so a peer declaring a 900 MB body makes us buffer 900 MB, and anything over 1 GiB throws out of stream.read(). Reject oversized declarations with a protocol error and destroy the connection instead of trying to satisfy them.
  • Fix the int32 overflow at message.js:25. ((fieldsLength + 7) >> 3) << 3 yields -16 for fieldsLength = 0xfffffff0 instead of 4294967280, so fieldsAndBodyLength can go negative. Use Math.ceil(n / 8) * 8 or apply the size cap before the arithmetic.
  • Guard the no-body case in message.unmarshall(). Line 73 calls msgBuf.read(message.signature) unconditionally, so every argument-less message — Hello, Ping, ListNames — throws TypeError: Cannot read properties of undefined. The streaming path at line 50 already guards correctly with if (bodyLength > 0 && message.signature); make the two agree. This is exported API and is used by bin/dbus-dissect.js.
  • Make readString reject rather than truncate. buffer.toString('utf8', pos, pos + len) silently clamps to the buffer end, so a corrupt length field produces a short string and no error. Validate pos + len against the buffer and check the trailing NUL.
  • Give the parse loop an error boundary. A throw currently escapes the 'readable' handler and becomes an uncaught exception. Catch it, emit it on the connection as 'error', and stop parsing that connection.

2.2 Isolate user handlers from the read loop

Severity: high — an ordinary application bug kills the process.

Distinct from §2.1 and worth its own change: message dispatch runs synchronously inside the read loop, so an exception in user code unwinds through the parser. Method-call handlers are safe because lib/bus.js wraps them in Promise.resolve().then(), but signal delivery is a plain synchronous emit:

Error: bug inside a user signal handler
at EventEmitter.emit (node:events:509:20)
at EventEmitter.<anonymous> (lib/bus.js:144:20) <- signals.emit
at index.js:110:14
at Socket.<anonymous> (lib/message.js:53:9) <- readable handler

Dispatch should be isolated so a listener that throws surfaces as a connection 'error' (or an uncaughtException-style hook) without taking down the parser or losing the rest of the read buffer. Care needed: this changes observable behaviour for anyone currently relying on the crash, so it wants a note in the changelog.

2.3 Rewrite the marshaller onto a single-buffer cursor writer — DONE

Landed in #307. Kept for the record; the measured result is at the bottom of this section.

Severity: high for throughput — up to 2000× on byte arrays.

lib/marshall.js and lib/put.js allocate a fresh Buffer for every scalar written, push it onto an array, and Buffer.concat at the end. Byte arrays are walked element-by-element, so an ay costs one allocation per byte: 1026 Buffer.alloc calls to marshal 1 KB, and 28 for a small method call.

Measured against a prototype cursor writer (grow-on-demand buffer, strings written via buf.write() + Buffer.byteLength with no intermediate, ay fast-pathed to a single copy). The prototype produced byte-identical output on 30/30 cases including non-zero start offsets:

caseshippedprototype
Notify-like (susssasa{sv}i)4.92 µs1.18 µs4.2×
single string (s)0.41 µs0.19 µs2.2×
ai, 10k ints1226 µs144 µs8.5×
as, 1k strings374 µs160 µs2.3×
ay from Buffer, 1 KB105.6 µs0.65 µs162×
ay from Buffer, 64 KB14 727 µs10.1 µs1456×
ay from Buffer, 1 MB293 204 µs141.7 µs2069×

The ay case is the headline: 3.5 MB/s against an ~8 GB/s memcpy ceiling. Reading the same array takes 0.95 µs, so writing is ~300 000× slower than reading for identical data. Anything moving images, audio or file contents over D-Bus hits this.

Fold into the same PR, since they touch the same code:

  • Fast-path ay when the value is a Buffer/Uint8Array: one length write plus one copy.
  • Memoise MakeSimpleMarshaller. marshall.js constructs a fresh object with fresh closures for every scalar written; there are only 13 types. Worth ~8% on its own — small, but free once the file is open.
  • Keep the validation. The prototype has none; the range and type checks in marshallers.js are load-bearing (test/unmarshall-basic.js asserts on them) and must be ported, not dropped.

This obsoletes lib/put.js, added in #299. That was the right minimal fix for removing the abandoned put dependency, but it inherits that package's allocation model.

Outcome (#307). lib/writer.js replaces lib/put.js and lib/align.js, both deleted. Validated by a differential test against the previous implementation: 1157/1157 byte-identical across every type, at twelve starting offsets each, plus error-message parity on the failure paths. Measured on the same machine as the numbers above:

casebeforeafter
Notify-like (susssasa{sv}i)5.41 µs2.18 µs2.5×
message.marshall (full call)10.71 µs5.28 µs2.0×
ai, 10k ints1226 µs403 µs3.0×
as, 1k strings374 µs150 µs2.5×
ay from Buffer, 1 KB105.6 µs0.67 µs158×
ay from Buffer, 1 MB293 204 µs127 µs2300×

ay now runs at 1.5–8 GB/s rather than 3.5 MB/s. The gap to the unvalidated prototype (2.18 µs vs 1.18 µs on the Notify case) is signature parsing, which §2.4 addresses.

Sequence with §3.2 (BigInt): both rewrite the same scalar paths. Either do BigInt first and rewrite once, or accept touching x/t twice.

2.4 Cache parsed signatures — DONE

Landed in #308.

Severity: medium — pure win, small diff.

parseSignature is called from readVariant for every variant value, so unmarshalling an a{sv} with 500 entries parses 500 signatures and costs 243.6 µs. On the write path it runs once per marshall() call plus once per variant, and 'yyyyuua(yv)' is re-parsed for every message.

Signatures come from a tiny set in practice. A bounded Map cache (capped, so a peer sending unique signatures cannot grow it without limit) is a few lines. Note the returned tree must then be treated as immutable — check no caller mutates it before landing this.

Outcome (#308). Cached in lib/signature.js, capped at 1000 entries with oldest-first eviction. No caller mutates a tree, but DBusBuffer.readVariant returns one to application code as variant[0], so cached trees are deep-frozen rather than merely documented as immutable.

casebeforeafter
unmarshall a{sv}, 500 entries243.6 µs124.2 µs2.0×
unmarshall Notify-like1.33 µs1.03 µs1.3×
marshall Notify-like2.18 µs1.86 µs1.2×
message.marshall (full call)5.28 µs4.13 µs1.3×

This is the first item in §2 to speed up the read path, which §2.1–§2.3 left untouched.

2.5 ay buffer views retain the whole message — DONE

Landed in #310.

Severity: medium — unbounded memory growth in long-lived processes.

dbus-buffer.js:119 returns this.buffer.slice(start, this.pos), a view sharing memory with the whole message. Verified: a 4-byte ay pulled out of a 4 MB message keeps the entire 4 MB ArrayBuffer alive. Retain a few small byte arrays from large messages and memory grows with traffic, not with data kept.

Also Buffer.prototype.slice is deprecated in favour of subarray (identical semantics), so this line should change regardless.

Decide deliberately: copy by default (safe, costs a memcpy) or keep the view and document it, ideally as an explicit ayBuffer: 'view' | 'copy' option. Copy is the better default — the current behaviour is a footgun that only shows up under load.

Outcome (#310). Copy by default; ayBuffer: 'view' opts back into the zero-copy view, and ayBuffer: false still yields a plain array. slice also became subarray.

The copy runs at 5–8.5 GB/s, so it costs 0.18 µs on a 1 KB ay and 117 µs on a 1 MB one. That is real, but small next to the socket read that delivered the message, and it is the difference between a 4 byte value retaining 4 bytes and retaining 4 MB. Throughput-sensitive callers that consume and drop the value promptly can set 'view'.

2.6 Accept big-endian messages — DONE

Landed in #311.

Severity: low frequency, but a spec violation.

constants.endianness.be is defined and never read. Byte 0 of the header — the byte order flag — is never consulted, and every read is readUInt32LE / readInt32LE. Flipping a message's flag to 'B' changes nothing: the reader ignores it and reads little-endian regardless.

The spec requires receivers to accept both byte orders; senders may keep emitting little-endian. Only bites when talking to a big-endian peer (s390x, some MIPS/PPC), which is why it has gone unnoticed.

Work: thread a byte-order flag through DBusBuffer and pick *LE/*BE readers from it. Mostly mechanical, but it touches every read method, so it deserves its own PR and a round-trip test against a hand-built big-endian fixture.

Outcome (#311). Byte order is read from header byte 0 and threaded into DBusBuffer, which selects *LE/*BE accessors per read. The 64-bit types needed care: both the bytes within each 32-bit word and the order of the two words flip. Anything that is neither 'l' nor 'B' is still a protocol error.

Senders still emit little-endian, which the spec permits. No measurable cost to the read path (1.09 µs vs 1.03 µs on the Notify case, within run-to-run noise).

Fixtures are assembled by hand with Node's own writeUInt32BE, not by round-tripping through this library, so a writer and reader sharing the same mistake cannot make the tests pass. That caught a bug in the fixture itself: g (signature) values take a one-byte length and no alignment, unlike s.

2.7 Backpressure on the write path — DONE

Landed in #313.

Severity: medium for high-throughput senders.

index.js:126 and :132 discard the return value of stream.write(), so a producer faster than the socket grows Node's internal write buffer without bound. There is no cork/uncork batching either, so emitting N signals in a tick issues N separate writes.

Work: respect the false return, expose 'drain' (or return a promise from connection.message()), and consider corking within a tick.

Outcome (#313). connection.message() now returns the writable's boolean, and the connection re-emits 'drain' — the same contract as stream.write(), so the idiom is the one Node users already know. Messages written in the same tick are corked into a single flush: ten messages in one turn of the event loop went from ten _write calls to one _writev.

Deliberately not returning a promise from message(). §3.1 will make the proxy layer promise-returning, and having the low-level method already resolve to something unrelated would collide with it.

2.8 UNIX_FD (h) support

Severity: feature gap — blocks whole categories of users.

signature.js parses h, but both directions throw. There is no SCM_RIGHTS handling anywhere. systemd, the XDG desktop portals and PipeWire all pass file descriptors, so those APIs are simply unreachable from this library.

Scoped 2026-07-29. Conclusion: not buildable today, and it does not need a major when it is. Every option below was measured, not read about.

It is additive, so it needs no major

We never send NEGOTIATE_UNIX_FD, so nothing changes for anyone until we do, and the daemon already agrees when asked:

daemon: OK a6343c06f036b0dfc55635016a69a2eb
daemon: AGREE_UNIX_FD

The protocol side is small: the SASL command above, header field 9 (UNIX_FDS, a u — note constants.headerTypeName stops at 8 today), and h as an index into the received fd array. None of it is a breaking change. This can land in any minor, whenever a transport exists.

The transport does not exist

optionresult
Node built-inNo public ancillary-data API. nodejs/node#53391 is closed as not planned. libuv supports it; Node never wired it to net
process.binding('pipe_wrap')Aborts the process for the fds d-bus carries — see below
node-unix-socketN-API prebuilds for 7 platforms, installs in 719 ms with no compiler, loads fine on Node 26 — but SOCK_SEQPACKET/SOCK_DGRAM only. Against d-bus's SOCK_STREAM socket: Error: Protocol not supported
usocketRight socket type, fd passing verified working — but nan + node-gyp, so it compiles on install and pulls 26 packages (node-gyp, tar, undici) into the runtime tree. No prebuilds
csocketLast published 2022; prebuilds target Node 4/6/8

The near-miss is worth recording. libuv's IPC-mode pipe does read ancillary data — it is how child_process passes handles — and it can be pointed at an existing socket with no addon at all:

const { Pipe, constants } = process.binding('pipe_wrap');
const pipe = new Pipe(constants.IPC);
pipe.open(existingSocket._handle.fd);
pipe.onread = () => {
const handle = pipe.pendingHandle; /* ... */
};
pipe.readStart();

But libuv classifies the incoming handle, and d-bus does not pass handles — it passes arbitrary file descriptors: regular files, memfds, pipes, DRM buffers. Tested both ways, on macOS/Node 26 and Linux/Node 24:

socket fd -> got a pendingHandle, read through it: "hello through the passed socket"
file fd -> Assertion failed: (type) == (UV_UNKNOWN_HANDLE) [Aborted]

That is a CHECK in Node's own C++ (stream_wrap.cc:277), so it is an uncatchable abort rather than an error we could fall back from. The technique works for the case it was published for — handing TCP sockets between processes — and fails on precisely the case d-bus needs. process.binding is also internal and deprecated.

What to do before 3.0/4.0 — DONE, and further than planned

The plan was to shape the seam and build nothing. In the event the seam turned out to be almost all of it: once a message is { bytes, fds } and h is the uint32 index the spec says it is, the only missing piece is a stream that can carry descriptors — and that is exactly what a caller can supply.

So the whole protocol is implemented and tested against a mock fd-capable transport, and UNIX_FD works today for anyone who brings their own stream. The package still depends on nothing, which was the point.

  • writeWithFds(bytes, fds) on the stream, and an 'fds' event, are the seam.
  • connection.canPassFds / connection.unixFdsAgreed report each half.
  • NEGOTIATE_UNIX_FD is sent only when the transport can carry one, and the server agrees only when its own can.
  • An fd-carrying message is never batched: ancillary data attaches to a write, not to a message.
  • Header field 9 is in the tables. It was missing, so a peer that sent one produced msg.undefined = 2 — latent only because we never negotiated.

The original text follows, because the transport findings are still why there is no built-in one.

Do not build it; there is nothing safe to depend on. But shape the transport seam, because that is the part that would otherwise force a major later.

Everything today assumes createStream() returns a byte Duplex and that a message is a Buffer. Carrying descriptors means a message is { bytes, fds } in both directions, which touches:

  • connection.message() and the write path, including the cork/uncork batching from §2.7 — fds must stay attached to their message across a batched _writev.
  • unmarshalMessages(), which would need to associate received fds with the message whose UNIX_FDS header claims them, not merely with the chunk they arrived in.
  • opts.stream, which lets a caller supply their own transport. That is the seam to define: an optional capability on the stream object (stream.writeWithFds?, stream.on('fds')) rather than a new option, so a caller — or a future optional dependency — can provide it without the core depending on anything.
  • bus.invoke and the service surface, where a handler receives or returns an fd alongside the body.

Defining that seam now, even with no implementation behind it, is what keeps UNIX_FD a minor when it becomes possible. Implementing it now is not worth the cost: the only viable dependency needs a compiler on every install, which undoes the property §1 spent three releases building and that #342 and #343 just strengthened further.

Until then, h fails with a message that says why, names the Node issue, and lists the APIs it costs you — so nobody has to rediscover this. See test/unix-fd.js.

2.9 Small non-canonical cleanups — DONE

Landed in #312.

Severity: low — batch them into one tidy-up PR.

  • DBusBuffer mutates the caller's options object, adding ayBuffer: true to the connection opts you passed in.
  • new DBusBuffer(buf, 0, null) throws, because typeof null === 'object' slips past the guard.
  • lib/unmarshall.js returns Buffer.from('') for an empty signature where every other path returns an array.
  • marshallers.js calls parseInt/parseFloat on values already validated as numbers.
  • lib/readline.js reads one byte at a time via stream.read(1). Handshake-only so the impact is negligible, but it is not idiomatic.
  • message.js never validates the protocol version byte (header[3]).

Outcome (#312). All done, except parseInt/parseFloat, which the §2.3 rewrite had already removed. Two are behaviour changes worth noting in the changelog: unmarshall('') now returns [] rather than an empty Buffer, and a message declaring a protocol version other than 1 is rejected as the spec requires instead of being parsed anyway.

readOneLine now reads whole chunks and unshift()s the remainder rather than calling stream.read(1) per byte. It only runs during the SASL handshake, so this is tidiness rather than throughput — but it is on the critical path, so it gained its own test file alongside the existing real-daemon handshake coverage.


3. High priority

3.1 Promise support for method calls

Issues: #9, #10, #295

The single most-requested change, and the biggest ergonomics gap against dbus-next. PR #295 is only +15/-3 in lib/introspect.js: if no callback is passed, return a promise. It is backwards compatible and should be reviewed and merged more or less as-is.

Then go further: add a bus.invokeAsync(msg) (or a promisify: true client option) so the low-level API is usable with await too, and export the whole proxy surface as promise-returning. Callback style stays supported.

3.2 Replace Long.js with BigInt ✅

Issue: #248, PR: #252

BigInt is native and removes the last non-trivial runtime dependency — and with it the ARMv6 long.js crash that forced the Homebridge fork to vendor their own copy.

Plan: read x/t with readBigInt64LE/readBigUInt64LE; accept bigint, number, string and Long-shaped objects on marshall. Gate the return type behind an option (ReturnBigInt) for one minor release alongside the existing ReturnLongjs, then flip the default in 0.7 and drop long.

Half done. The option, the BigInt read path and bigint on marshall are all in — see docs/deprecations.md.

It shipped as returnBigInt, not ReturnBigInt as proposed above. Every other option in the API is lowercase-first camelCase; ReturnLongjs is the sole outlier and only keeps its capital R because callers already set it. Copying it would have doubled a historical accident rather than leaving it behind. What remains is flipping the default and dropping long, which is a break and belongs in 2.0 with the rest of the type-system change. The gap is deliberate: bigint is not a drop-in for number and the failure mode is a TypeError in production, so it earns a release of opt-in first.

Removing the internal use — done in 0.11

Those two were being conflated. Dropping the dependency needs ReturnLongjs gone, which is a break. Dropping the internal use needed nothing, and was costing us:

  • Every 64-bit write built a Long and handed the writer its two 32-bit words, so a bigint went out through a decimal string — Long.fromString(val.toString()) at 0.217 µs, 53% of the whole marshall call, four times what writing the eight bytes costs. We shipped returnBigInt as the forward-compatible option and then charged the people who took it.
  • The default read path built a Long purely to call .toNumber(), which is the same lossy conversion with a dependency in the middle.

bigint is now the internal representation both ways: writer.int64() uses writeBigInt64LE, and the default read is Number(readBigInt()). Long.js is still accepted on input — recognised structurally by its {low, high, unsigned} shape, so it costs no import — and ReturnLongjs still returns genuine Long instances. require('long') now appears in exactly one file, lib/dbus-buffer.js, for that option alone.

Writing a bigint went 0.408 µs → 0.155 µs, now faster than the number path it used to be slower than.

Checked against a 150-case behavioural baseline captured before the change — every input form for x and t, in containers, and every read option — 144/150 identical. The six that changed are all garbage input, and all improvements:

inputbeforeafter
marshall('x', [{}]), [[]], [true]eight zero bytes, silentlythrows
marshall('t', [{}]), [[]], [true]Longjs object is signed…Error converting object to 64bit integer 'boolean'

Long.fromBits(undefined, undefined, undefined) is ZERO, so passing a boolean where a 64-bit integer belonged marshalled as 0 with no complaint — the same silently-wrong class as the Properties.Set bug in §1. The t messages were merely nonsense: {} is not a "Longjs object" of any signedness.

Removing the dependency — done in 2.0

One line of code, gated on a break. What happened, against the plan:

  1. Deprecate louder (any minor). Already done, since 0.6 — this step was written here in error. ReturnLongjs is a runtime deprecation, not a documentation-only one, and index.js has warned on it all along:

    (node:32313) [DBUS_DEP0001] DeprecationWarning: The 'ReturnLongjs' option is
    deprecated. 64-bit values (x/t) become native BigInt in 2.0, ...

    Once per process, and node --throw-deprecation locates the call site. Nothing to do here; the warning has been running for five releases, which is as much notice as the option is going to get.

  2. Deleted the option. ReturnLongjs and readLong() are gone from DBusBuffer and long is gone from package.json. Runtime dependencies are down to one (xml2js). It was the whole cost, as predicted — the surrounding code stopped caring in 0.11.

    One thing the plan did not say: passing it now throws rather than being ignored. Code that sets it expects a Long, and ignoring it would surface as value.toNumber is not a function at whatever point the value is first used — possibly nowhere near the connection. ReturnLongjs: false is accepted, since it asked not to be given Longs and is not being given any.

    long moved to devDependencies: the marshaller still accepts a Long structurally, and the tests need the real thing to build one.

  3. Flipped returnBigInt to the default, alongside the plainValues flip, in one release. Both are the same kind of change to the same values, so a consumer that has to think about bigint arithmetic and dict shapes does one upgrade rather than two — and the codemod/lint story covers them together. The flip went in first and the removal followed, so the two diffs stayed separately reviewable.

Two things the implementation turned up:

  • A service needs the option too. It reads its own arguments through the same parser, so a service without it receives a large x as a lossy number — and then fails the 53-bit check when it tries to send that back. Loud, at least, rather than silently truncating.
  • JSON.stringify throws on a BigInt, so three of the marshaller's error messages used to replace the error they were describing with TypeError: Do not know how to serialize a BigInt. Fixed alongside; worth remembering wherever else a diagnostic renders user values.

3.3 TypeScript declarations

Issue: #276

Ship a hand-written index.d.ts in the package (not DefinitelyTyped, so it cannot drift). This is table stakes in 2026 and is likely the biggest single driver of users choosing dbus-next. Do it after 2.1 so the promise-returning signatures are typed correctly the first time.

3.4 Call timeouts and connection-death handling

Issues: #137, #20, PR: #213

Today a pending call whose reply never arrives leaks its entry in bus.cookies forever, and if the connection dies every in-flight callback is silently dropped. PR #213 fixes the second half — on end/error, fail every pending cookie. Add to that a per-call and per-client timeout option that rejects with a org.freedesktop.DBus.Error.NoReply-shaped error.

This is a correctness issue, not a nicety: long-lived daemons using this library grow unboundedly today.

Both halves are now done. The per-call and per-client timeout option, with AbortSignal support, shipped in 0.6. Connection death fails every pending call with a ConnectionClosedError as of 0.7, which absorbs #213 — see docs/migrating-to-0.7.md.

The ERR_STREAM_WRITE_AFTER_END half of #20 is also fixed: closing a connection during the SASL handshake made the next handshake write land on an ended socket, which surfaced as an unhandled 'error' and killed the process. It was a race, so it appeared intermittently — it took down one CI job in seven before being tracked down. The handshake now abandons quietly when the caller has closed the stream.

Not the whole of #20: that issue reports ECONNRESET on the read side after an early end(), which is a different path and still open.

What remains under this heading is the read half of #20, and #137: a default timeout is deliberately held back to 3.0 because it makes previously-hanging calls start failing.


4. Medium priority

4.1 Variant handling

PR: #143 (mvduin, with tests)

Variants currently unmarshal as [signatureTree, [value]] — the parsed tree leaks the internal representation into the public API, and every caller writes result[1][0]. Review #143, and consider making variants return the plain value with the signature available separately, behind an opt-in option first.

Related: #3, #132, #147, #67 — all variations on "how do I deal with a{sv}". A documented, ergonomic dict/variant story would close roughly a dozen issues at once.

Our own call sites — the internal half of the 2.0 change, found with npx dbus-native lint lib index.js. Done, and they turned out to be more than housekeeping:

sitereadwhat it is
lib/message.js:120field[1][1][0]header fields — the blocker
lib/message.js:183field[1][1][0]header fields
lib/stdifaces.js:175msg.body[2][1][0]the value argument of Properties.Set
lib/introspect.jsval[1][0]the reply from Properties.Get

A message header is a(yv), so header field values are variants, and the DBusBuffer that parses them is built with the connection's own options. Flip the variant shape and header parsing dies on the first message — not on some edge case, on everything. Verified: with these sites reverted, a simulated 2.0 parser takes the connection down immediately at message.js:120.

All four now read through variantValue(), which handles either shape. The alternative was to parse headers with fixed options so a user-facing value option could not reach them; variantValue() was preferred because it stays correct after 2.0 rather than merely insulating against it, and it costs 0.018 µs per message against ~1 µs to unmarshall one.

toPlain() also now recurses into plain objects. It returned non-arrays unchanged, so on the new shape it would have left a nested variant unconverted — and the whole point of the 0.6 helpers is that they are the identity there.

Guarded by test/integration/future-shape.js, which patches the parser to produce the 2.0 shapes and runs a real exchange over a real daemon: headers, method calls, Properties.Get/Set, GetAll as a plain object, toPlain as the identity, and a value read in the new shape written straight back out (which §4.2 made possible).

Plus four ReturnLongjs branches in index.js and lib/dbus-buffer.js, which §3.2 removes. The two hits the linter still reports are both benign — a Map iteration it flags as (possible), and the marshaller deliberately reading the classic shape — which is about the right false-positive rate for rules pointed at consumers.

The option shipped as plainValues: one flag flipping both shapes together, opt-in now and the default in 2.0, exactly as returnBigInt did in §3.2. Flipping only dicts was never worth shipping — a{sv} is the case everyone wants, and { Name: [tree, ['x']] } is half-migrated and worse than either end state, which is also why it is not called hashAsObject.

signaturedefaultplainValues
v[signatureTree, [value]]value
a{sv}array of pairs, values variants{ key: value }
a{ss}array of pairs{ key: value }
a{us}array of pairsunchanged — see below
a(ss)array of arraysunchanged, it is not a dict

Non-string keys stay as pairs. A JavaScript object key is always a string, so a{us} read as an object turns the key 1 into '1', and with returnBigInt a 64-bit key stringifies and loses precision on the way back. Quiet corruption is worse than an inconvenient shape, and toPlain() still converts them for anyone who wants that.

Reading only. #4.2 had already made the marshaller take plain objects and Variant, so the two halves meet: a value read under this option can be written straight back out.

test/integration/future-shape.js was written against a monkey-patched parser because the option did not exist; it now sets the real option, so the simulation is gone and the guarantee is exercised directly.

What remains for 2.0 is flipping the default, removing the flag, and dbus-native/compat's withClassicTypes() for code that cannot move yet.

4.2 Marshall JS objects as a{sv}

Issues: #3, #91, #132

There was a // TODO: serialise JS objects as a{sv} in lib/marshall.js and a disabled test (test/js-types.js) waiting for it. Combined with 3.1 this is the "just let me pass a plain object" story users keep asking for.

Done. A plain object is accepted anywhere a dict is expected, and writes byte-identical output to the array-of-pairs form, which is untouched. Inside an object, a{sv} values get an inferred signature; Variant overrides it and reaches the types inference cannot produce (u, y, o, structs, av).

Three decisions worth recording:

  • Inference only inside a plain object. The pairs form stays explicit and infers nothing. This is what makes the rule "inside an object, an array is an array" safe to state: there is no position where ['s', 'hello'] could be either a two-string array or a classic variant pair, so nothing has to guess. Values reached through the pairs form keep meaning exactly what they did.
  • Refuse rather than guess. An empty array, a mixed array, null, NaN and unrecognised objects throw with a message naming the fix, instead of picking a plausible type. A d-bus array is homogeneous, so [1, 'a'] has no signature; saying so beats writing ai and truncating.
  • Only Object.prototype/null prototypes count as dicts. A class instance is rejected, because being wrong there writes a garbled message rather than failing.

Two things the implementation turned up:

  • A variant could not be written back after being read. The reader produces [parsedTree, [value]] but the writer wanted a signature string in that first slot, so handing a value from one service straight to another failed with a confusing complaint about type 'g'. It had nothing to do with plain objects — it just showed up immediately once a test tried to echo a dict. The writer now accepts the reader's shape, recursively.
  • The disabled test could never have passed. It asserted that a plain object came back out of unmarshall, which is the 2.0 read shape and not part of this change. Rewritten to round-trip through toPlain(), which is the 0.6 helper for exactly this and becomes the identity in 2.0.

Not done: the read side. hashAsObject — the option the old test's name referred to — belongs with §4.1 and the rest of the 2.0 type change.

4.3 High-level client/service API

PR: #251 (acrisci)

Ports the dbus-next high-level interfaces on top of the existing low-level ones. The PR is from 2018 and will not apply cleanly, but the design is proven and it is additive by construction. Worth rebasing rather than redesigning — and it is the most credible path to reconciling the two projects.

4.4 Properties: signals and access control

Issues: #81, #89, #75, #91, #236, #117

Properties.Set now actually writes the value, but:

  • PropertiesChanged is never emitted when a property changes.
  • Interface descriptors have no way to declare read/write/readwrite; interfaceToXML hardcodes access="readwrite".
  • GetAll on an interface with no properties still misbehaves (#102).

Done, except that the third item was wrong.

PropertiesChanged is now emitted whenever Properties.Set writes, and bus.emitPropertiesChanged() lets a service announce changes it makes itself — an ordinary assignment to the implementation object cannot be observed without redefining its accessors, which is not a reasonable thing for exporting an object to do to it.

Properties may now be declared as { type, access } as well as a bare signature, the XML advertises what was declared, Set refuses a read-only property with PropertyReadOnly, Get refuses a write-only one with AccessDenied, and GetAll omits what cannot be read. The bare-signature form still means readwrite, so nothing existing changes.

Receiving a PropertiesChanged — the client half — is §4.6.

On the third item: #102 is closed, and it was never about an interface with no properties — it reports GetAll failing on a property whose value is an empty array (as = []). Both cases were checked against the current marshaller and both work. Whatever broke in 2015 was fixed somewhere along the way; the roadmap entry had simply gone stale.

4.5 Finish the server/broker

Done, in three parts.

lib/server-handshake.js was a transcript of someone's 2014 session played back verbatim — a hardcoded cookie and GUID, an unconditional REJECTED, and a console.log per connection. It is now the state machine from the specification, with EXTERNAL, DBUS_COOKIE_SHA1 and ANONYMOUS, a GUID per server, an auth timeout, and an authorize hook. EXTERNAL cannot be verified the way the spec intends, because Node has no SO_PEERCRED; the default is that the peer must claim to be the user this process runs as.

lib/match-rule.js parses and evaluates match rules. The grammar is described in prose rather than given formally, so the edge cases were established against dbus-daemon and test/integration/match-rules.js keeps the two agreeing.

lib/broker.js is the bus: unique names, RequestName/ReleaseName with the flags and the queue, the rest of org.freedesktop.DBus, and routing — unicast by destination, signals by match rule. npm run test:integration:broker runs the whole integration suite against it, and all of it passes, which was the point: contributors no longer need dbus-daemon installed.

Three things it deliberately is not:

  • No security policy. Any client may own any name and call anyone. The system bus refuses arbitrary names for good reason; this does not.
  • No service activation. StartServiceByName always answers ServiceUnknown.
  • No eavesdropping. BecomeMonitor is unimplemented, so nothing sees unicast traffic addressed elsewhere and dbus-monitor is of limited use against it.

One piece of internal debt worth naming: the broker re-encodes what it forwards. It unmarshals a message and marshals it again, so every value has to survive the round trip — and by default a 64-bit integer does not, which is why the broker's own connections read them as BigInt. A router should keep the body bytes and rewrite only the header fields it must; that needs the message layer to hand back the raw frame alongside the parsed one, which it does not do today. Until then the round trip is faithful but the cost is real, and a value shape the reader and writer disagree about would show up as a routing bug rather than a parsing one.

4.6 Signals on the client side

Issues: #117, #75, #236

The other half of §4.4. A service emits PropertiesChanged as of 0.9; this is what it takes to receive one.

Done. lib/introspect.js parses <signal> elements, so iface.$signals lists what an interface declares in the same [signature, ...argumentNames] shape a service is exported with. That closes the // TODO: introspect signals left in the file, and makes the runtime agree with dbus-native types, which had been emitting typed on() overloads for signals the runtime never recorded.

Three things the implementation turned up:

  • off() could silently fail to unsubscribe. The listener bookkeeping was two flat arrays shared by the whole interface, so it could not tell two signals apart. Removing the last listener of one signal emptied them for every signal on that interface; the next off() then built a wrapper it had never registered, removed nothing, and left the listener firing with no way to stop it. Now keyed per signal name, with a count so a listener added twice needs removing twice, as EventEmitter does. Reproduced against a real daemon before the fix, and pinned by a test that fails on the old code.
  • The subscription was installed one round trip too late. The listener was registered in the AddMatch reply handler, but the daemon starts routing when it processes the rule — necessarily before the reply gets back — so a signal arriving in that window was dropped. Registering first and rolling back on failure closes it.
  • A failed AddMatch had nowhere to go. It was a throw from inside a reply handler, which the read loop turns into a connection handlerError. That is still what on() does, since anything else would be a break, but $subscribe/$unsubscribe now return promises: they reject with the DBusError, and resolving means the rule is actually in place. once(), removeAllListeners() and listenerCount() came along with them.

Not addressed: the match rule still omits sender, so two services exporting the same object path deliver each other's signals. Fixing it properly means putting the sender in bus.mangle's dispatch key, which is public API and belongs with the lifecycle work rather than here.


5. Lower priority / opportunistic

  • launchd: address family (#95) — done. launchd:env=VAR names an environment variable rather than a path, and the socket is looked up with launchctl getenv, falling back to our own environment when launchd has no answer. On macOS sessionBus() now defaults to launchd:env=DBUS_LAUNCHD_SESSION_BUS_SOCKET when DBUS_SESSION_BUS_ADDRESS is unset — which is the normal state there — so it works with no setup beyond a running bus.

    The lookup is spawnSync, measured at 4 ms, once per connection and before any I/O. Making it async would have turned createConnection() — and so sessionBus() — async for every caller on every platform, to fix one transport on one of them. Reading process.env alone would have been free but wrong: the point of the transport is that the variable lives in the launchd session, not necessarily in ours.

  • Activatable service lookup (#133) — StartServiceByName exists but the new API never consults it.

  • dbus-send equivalent (#56) — a small CLI on top of the library; good first issue.

  • Name validation (#309) — done. lib/names.js implements the four rule sets, and the predicates are exported. Enforced on what we send: exportInterface (path, interface name, every member name), sendSignal, sendError, and the o marshaller, which covers the path header of every outgoing message and closes the // TODO: verify object path for 'o'.

    Three decisions worth recording. Throw rather than warn — the issue left this open; an invalid name yields a message no peer can route, so the code was already broken and the useful place to say so is where the name was written. Signals get validated but ordinary calls do not: a bad interface or member on a method call comes back as an error from the daemon or the peer, whereas a signal gets no reply and simply vanishes. Validating all four header fields on every message measured 0.135 µs against a 4.13 µs marshall (3.3%) and is still available if the silent-failure argument ever extends to calls. Incoming names are not checked — the spec only requires that a receiver can reject one, readString already bounds-checks, and refusing a sloppy peer's path would break interop over something we pass through harmlessly. The commented-out check in dbus-buffer.js is now a note saying so rather than a TODO.

  • Introspect signalsdone, see §4.6.

  • Several out arguments from a service method (#114) — done. The interface descriptor has always accepted an output signature with several complete types, and interfaceToXML advertised them, but the reply body was always [result] — one value. So the shape was expressible, promised to callers, and impossible to satisfy. Worse, the marshalling failure threw out of a promise continuation, so it took the service process down and the caller waited for a reply that could never arrive.

    Found while auditing the issue tracker, not from a report: the workaround (declare a struct, (si)) is close enough to the right answer that nobody seems to have pushed past it. org.freedesktop.Notifications's GetServerInformation is the case in the original issue, and declares four.

    Several out arguments now mean "return an array of them"; one still means the value, a struct is still one value, and null still means no reply body. A handler that returns the wrong shape gets an error reply naming the method and its declared signature, instead of an unhandled rejection.

  • Double serial incrementdone, and it was not harmless. Chasing it turned up that nothing ever wrapped bus.serial: it was a bare ++ from 1, and the serial is a uint32 in the header, so past 4294967295 the marshaller rejected every outgoing message with Number outside range and the connection was dead for good. That is ~50 days at a thousand messages a second, or ~25 for a signal-heavy service, which the double increment was halving. Well within the life of the long-running daemons this library is for. nextSerial() now wraps to 1 (0 is not a valid serial), and the stray second increment is gone. Related to #126.

  • lib/address-x11.js requires x11, which is not a dependency, so requiring it throws. Removed in 2.0. Documenting it as an optional extra was the smaller half-measure and it stayed wrong: the file shipped in lib/, so it was reachable through the exports map, and the only thing it could do for anyone who found it was throw Cannot find module 'x11'. It read the bus address off an X11 selection, which is a fallback for finding a session bus started without DBUS_SESSION_BUS_ADDRESSbusAddress covers that for anyone who can determine the address by any means at all.

  • Drop hexydone, and with no replacement.

    The entry above was stale: bin/dbus-dissect.js had stopped using it, so the only caller left was lib/portforward.js. And once the hexdump was gone, portforward.js was a strict subset of dbus-dissect.js — the same forward from a TCP port to the bus socket, the same default port, the same abstract socket handling, but printing raw bytes where dissect prints decoded messages. For a d-bus debugging tool, decoded is what you want.

    So the script went too, rather than writing a hexdump helper to keep it alive. It exported nothing and nothing referenced it. Runtime dependencies are now two (long, xml2js), and long goes when BigInt becomes the default in 2.0.

    Worth recording, since it nearly shipped: a like-for-like replacement was written and checked against hexy first — 1008/1008 byte-identical outputs. The one difference was that hexy prints nothing at all for 0x7f, shifting its own ASCII column left by a character on any message containing a DEL byte. Reproducing that faithfully would have been silly; not needing the code at all was better.

Issue-tracker hygiene

Roughly a third of the open issues are dead Greenkeeper bots (#255#273) and superseded Dependabot PRs. Closing those in bulk would take the tracker from 65 open issues to something a new contributor can actually read. Several question-style issues (#88, #114, #132, …) are really documentation gaps and should be converted into README sections and closed.


6. Toward 1.0

Superseded by RELEASE_PLAN.md. The single-big-major idea below was split into a series of narrow ones, each small enough to document and tool properly: 1.0 errors, 2.0 the type system, 3.0 lifecycle, 4.0 ESM. 0.6 shipped the additive preparation; 0.7 makes errors real Errors (docs/migrating-to-0.7.md). The paragraph below is kept because it is still an accurate description of the destination.

A plausible 1.0 is: promises by default, BigInt for 64-bit types, shipped TypeScript types, call timeouts, and a variant/dict API that does not leak the parser's tree. That is a coherent breaking change worth a major version, and it would make this the obvious choice again for anyone currently picking between three half-maintained packages.

Section 2 is mostly orthogonal to that: the read-loop hardening (§2.1, §2.2) and the marshaller rewrite (§2.3) are behaviour-preserving for well-formed traffic and should ship in patch and minor releases as they land, not wait for 1.0. Only the ay copy semantics (§2.5) and any variant change (§4.1) need a major.