Skip to content

feat(bindings),docs: a DNS-SD mapping for service descriptors and a Python bridge - #489

Merged
bahdotsh merged 7 commits into
mainfrom
feat/headless-dns-sd-services
Sep 30, 2026
Merged

bahdotsh merged 7 commits into
mainfrom
feat/headless-dns-sd-services

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Summary

Services can now be found on a LAN as well as across the mesh, and Python gets its first application-level service wrappers.

  • A specification chapter, docs/spec/dns-sd-mapping.md, lays a ServiceDescriptor out as a DNS-SD instance (RFC 6763): the subtype _svc._sub of the _offlineprotocol._tcp type the peer-stream chapter already fixes, a digest instance name, a TXT record and its bounds, and what an implementation may and may not do with a record it did not sign.
  • bindings/python/offline_protocol_sdk/services.py: Services wraps the generated MeshServices with the copy of this node's registrations the engine cannot enumerate, and refuses a response status outside the engine's closed set with the reason.
  • bindings/python/offline_protocol_sdk/dnssd_bridge.py: DnsSdBridge publishes those registrations and imports the LAN's, over the existing optional lan extra (python-zeroconf, imported at start(), never by the base install).
  • docs/service-discovery.md is corrected where it disagreed with the engine, and the services crate's own MeshServices doc comment with it.

The invariants, and the failures they prevent

Rule Failure without it
A LAN record proves nothing: every import carries source: "lan" on the event and on the record An application that files a LAN import beside a mesh discovery lets any host on the LAN name any address as the provider of any service
A LAN record is never re-advertised on the mesh: imports go to the bridge's registry and the application's events, never to register_service A registration made from an unsigned LAN record would go out in signed discovery responses under this node's identity, and every mesh peer would trust the forged claim on the strength of that signature
A field that does not fit is refused, never truncated A truncated service id names a different service
A service instance is not a peer hint: a peer-stream browser ignores any record carrying sid Each published service would be one more connector to the same host

The second rule is held at the seam that matters: a test imports a record and asserts the mocked engine saw no call at all (mesh.mock_calls == []).

The mapping

Piece Value
Type and subtype _svc._sub._offlineprotocol._tcp.local.; the instance itself is named under the base type
Instance name svc- plus the first sixteen hex digits of SHA-256(address ‖ 0x00 ‖ service_id): twenty octets, deterministic across restarts
SRV The publisher's own host name (the peer-stream record's, when it has one), the interface addresses, the peer-stream port or 0
TXT txtvers=1, sid=, ver=, addr=, then one c.<key>=<value> per capability, keys in the byte order of their lower-case form

TXT keys are case-insensitive (RFC 6763 section 6.4): the reader folds a key before the fixed-key match and the duplicate check, so SID= is sid= and c.Foo imports as foo; the publisher refuses a descriptor whose capability keys collide under case folding rather than drop one (a different claim) or publish both (a duplicate). A duplicated key, under folding, makes the whole record not this mapping's; the chapter says how that relates to the RFC's keep-the-first rule.

The refusals

Bound Value Source
One TXT string 255 bytes RFC 6763 section 6.1
TXT key printable ASCII without =, so a capability key must be too RFC 6763 section 6.4
sid 200 bytes The engine's 256 does not fit one string
Whole record 1300 bytes, one length octet per string RFC 6763 section 6.2
Instance name 63 octets (the name is 20) RFC 6763 section 4.1.1

A registration that does not fit is kept on the mesh and not published, with a warning that names the service id. So is one the responder refuses (a name conflict, a closed socket), whether at start() or on a registration made while running: the listener path runs in a task nobody awaits, and without a guard a refusal there surfaced only as "Task exception was never retrieved" at collection, without the service id. The reader applies the same bounds to what it imports.

Lifetime

A responder that dies without a goodbye leaves its records to age out of every browser's cache, 75 minutes for the PTR, and a browser reports an instance once, again only after that cache has forgotten it. The bridge therefore keeps two sets: the browsed set (every name the browser reported and has not reported gone; a name leaves it only on the browser's Removed) and the listed set (what the application sees). The sweep re-resolves every browsed name at half the time to live (default 300 s), listed or not, and then drops any listed entry still older than the whole. Resolve comes before drop: with the sweep at half the time to live, one missed window puts the next attempt on the drop boundary, and dropping first showed the application an empty list during that resolve and a duplicate service_discovered when it answered, where an answer refreshes the entry and shows nothing (pinned by a test with an explicit clock that observes the listed set from inside the resolve). A neighbour that misses two windows is dropped from the listed set and comes back on the next resolve that answers; delivery is at least once, and the chapter says so. Removal is the bridge dropping its own entry; it never calls unregister_service, because the engine never held the entry and the id may be one this node offers itself.

What the guide had wrong

Each corrected against the code in crates/offline-protocol-services/src/services.rs:

  • Discovery responses go to the peer the query came from, never to the originator field, and each hop forwards them toward the originator. The guide said "directly to the originator" in three places; the crate's own doc comment said multi-hop relay was "planned for a future release" while the code does it.
  • The response status is one of exactly three values, refused on send and dropped on receipt. The guide called it application-defined in two places.
  • The version is an opaque string; nothing parses it. The guide called it a semantic version.
  • The peer-tracking hook is on_neighbor_discovered; the guide named one that does not exist.
  • ServiceId also refuses whitespace-only ids, ids over 256 bytes and the __ prefix; the guide said only "non-empty".
  • The limits table gains the initial broadcast (20), the gossip fanout (5), the dedup ceiling (10,000), the payload, body and method bounds, and the status set.

Licensing

THIRD-PARTY-NOTICES.md is generated over the Rust crates and kept byte-identical across the three packages, so it cannot carry a Python extra. The Python README's License section gains an "Optional dependencies" paragraph: python-zeroconf (LGPL-2.1-or-later) and ifaddr (MIT) are runtime dependencies pip installs with [lan], never redistributed in the wheel, and imported only when a manager or bridge is asked to advertise or discover.

Validation

  • Python: test_services.py (22 tests), test_dnssd_bridge.py (79 tests) and the touched test_peer_stream_manager.py pass on 3.12, 3.13 and 3.14; the three files were repeated 40 times on each interpreter, and the bridge file again 40 times on each after the sweep-ordering fix; the whole suite passes once on 3.14. CI adds 3.10.
  • Mutation-checked: 45 mutants over dnssd_bridge.py, services.py and the sid filter in peer_stream_manager.py, each failing at least one test, the sweep order swapped back among them. Two survivors along the way each found a missing test (a direct re-publish leaving two responder handles; a failed sweep resolve not counting as an attempt), both added.
  • The chapter's bounds, the subtype and the status set are pinned as literals in the tests (C5), not read from the modules.
  • The base install is checked in a fresh interpreter with zeroconf blocked: both modules import, and only the responder's constructor names the missing extra.
  • Rust: cargo fmt --all -- --check, cargo clippy -p offline-protocol-services -- -D warnings, cargo test -p offline-protocol-services --lib, rustdoc under -D warnings for that crate, and the stream-framing chapter guard (cargo test -p offline-protocol-transport --test stream_framing_vectors) all pass.
  • One manual smoke against the real responder on this machine, not committed: a publisher and a browser over the real python-zeroconf, on one host. The browser received the source: "lan" event with the right provider and capabilities, the resolved record had port 7878 and the publisher's own host name, peers_from_record on that record yielded nothing, and the import disappeared when the registration was withdrawn.

Review round

A fresh-context review found seven things, all fixed here with tests: a dropped import never came back (the browsed set above); the chapter named unregister for removal; TXT key case; the unretrieved task exception; the licensing paragraph; the seam test pinning two methods rather than the engine; and a subtype-only listing called conforming (RFC 6763 section 7.1 defines a subtype as an additional PTR to an instance listed under the parent type; the chapter now says some responders answer only the subtype browse, python-zeroconf among them, and a browser relies on neither). A second pass found the sweep dropping before it resolved; the order is swapped, pinned and mutation-checked, and the chapter states it.

Not in this PR

  • No Swift or Kotlin bridge. The mapping is a chapter; the mobile bridges have no application-level service wrappers to hang it on yet.
  • No engine change and no UDL change. The engine never sees a LAN record; that is the point.
  • The join between a LAN import and a later mesh response for the same (address, service_id) is described in the chapter and left to the application.

Notes for reviewers

  • No real mDNS responder is exercised by the suite, by design: the mapping is tested as pure functions on bytes and the bridge through a fake of the five calls it makes. The one live run is the manual smoke above.
  • python-zeroconf answers a browse of the subtype only for an instance registered with the subtype; Bonjour and Avahi list it under the base type as well. The peer-stream sid filter exists for those responders.
  • The SRV port is the peer-stream port or 0. A service instance is not an endpoint; the port is there so a host publishing both records publishes one consistent SRV target.

A ServiceDescriptor as a DNS-SD instance under the subtype _svc._sub of the
_offlineprotocol._tcp type the peer-stream chapter fixes: a digest instance
name, a TXT record (txtvers, sid, ver, addr, one c.<key> per capability) and
its bounds, refused rather than truncated. An imported LAN record is
unsigned: it carries source "lan", lives in an application-level registry
and is never registered with the engine, because a registration made from
it would go out in signed discovery responses under this node's identity. A
peer browser ignores a record carrying sid, so a published service is not
one more connector to the same host.

The service discovery guide is corrected against the services crate:
responses go to the peer the query came from and are forwarded toward the
originator, the status set is closed, the version is opaque, the
peer-tracking hook is on_neighbor_discovered, and the limits table gains
the fanout, dedup and size constants. The crate's own doc comment said
multi-hop response relay was planned while the code does it.
Services wraps the generated MeshServices with the copy of this node's
registrations the engine cannot enumerate, changed only after the engine
accepted, and refuses a response status outside the engine's closed set
with the reason. DnsSdBridge publishes those registrations on the LAN and
imports the LAN's into a registry of its own under the DNS-SD mapping
chapter, over the existing optional lan extra imported at start(): an
import is delivered as service_discovered with source "lan" and never
reaches register_service; a descriptor that does not fit the record is kept
on the mesh and not published; an import is re-resolved at half its time to
live and dropped at the whole. The peer-stream record reader ignores a
record carrying sid.

The chapter's bounds, the subtype and the status set are pinned as
literals in the tests (C5). The suite drives the bridge through a fake of
the five responder calls; no real mDNS is exercised by it.
Keys are case-insensitive (RFC 6763 section 6.4): a reader folds a key
before it looks it up, a publisher refuses a descriptor whose capability
keys collide under folding, and the chapter says how its whole-record
refusal on a duplicate relates to the RFC's keep-the-first rule. The
importer keeps a browsed set apart from the listed set, because a browser
reports an instance once and again only after its cache has forgotten it,
so an importer that stopped re-resolving a name when it left the listed
set would lose a neighbour that missed one window for up to 75 minutes;
delivery is at least once. Removal is the importer dropping its own entry
and never unregister_service, which the earlier sentence named. A
subtype-only listing is not called conforming: section 7.1 defines a
subtype as an additional PTR to an instance listed under the parent type.
…fused publish

A dropped import never came back: the sweep re-resolved listed entries
only, and python-zeroconf reports a name again only once its cache has
forgotten the PTR. The bridge keeps the browsed set apart from the listed
set, re-resolves every browsed name at half the time to live whether
listed or not, gates listing on the time to live, and drops a name from
the browsed set only on the browser's Removed; a resolve that lands after
Removed lists nothing.

TXT keys are folded to lower case before the fixed-key match and the
duplicate check, and a descriptor whose capability keys collide under
folding is refused. A publish or withdraw scheduled from the registry
listener runs under a guard that logs the service id and the reason, where
a responder refusal used to surface as an unretrieved task exception at
collection. The seam test asserts the mocked engine saw no call at all.
The Python README names python-zeroconf and ifaddr as the lan extra's
runtime dependencies under the License section.
With a sweep at half the time to live, one missed window puts the next
attempt on the drop boundary. Resolving first refreshes an entry whose
resolve on the boundary answers; dropping first would show the application
an empty list during the resolve and a duplicate announcement after it.
The sweep dropped listed entries older than the time to live before it
re-resolved browsed names older than half of it. With the sweep at half
the time to live, one failed resolve put the next attempt on the drop
boundary, so a single missed window whose next attempt succeeded was
observed as an empty listed set during the resolve and a duplicate
service_discovered after it. The two loops are swapped: an answer on the
boundary now refreshes the entry and emits nothing. A test with an
explicit clock observes the listed set from inside the resolve and pins
the ordering; swapping the loops back fails it.
@bahdotsh
bahdotsh merged commit 84a171a into main Sep 30, 2026
22 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 30, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant