feat(bindings),docs: a DNS-SD mapping for service descriptors and a Python bridge - #489
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Services can now be found on a LAN as well as across the mesh, and Python gets its first application-level service wrappers.
docs/spec/dns-sd-mapping.md, lays aServiceDescriptorout as a DNS-SD instance (RFC 6763): the subtype_svc._subof the_offlineprotocol._tcptype 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:Serviceswraps the generatedMeshServiceswith 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:DnsSdBridgepublishes those registrations and imports the LAN's, over the existing optionallanextra (python-zeroconf, imported atstart(), never by the base install).docs/service-discovery.mdis corrected where it disagreed with the engine, and the services crate's ownMeshServicesdoc comment with it.The invariants, and the failures they prevent
source: "lan"on the event and on the recordregister_servicesidThe 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
_svc._sub._offlineprotocol._tcp.local.; the instance itself is named under the base typesvc-plus the first sixteen hex digits ofSHA-256(address ‖ 0x00 ‖ service_id): twenty octets, deterministic across restartstxtvers=1,sid=,ver=,addr=, then onec.<key>=<value>per capability, keys in the byte order of their lower-case formTXT 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=issid=andc.Fooimports asfoo; 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
=, so a capability key must be toosidA 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_discoveredwhen 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 callsunregister_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:originatorfield, 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.on_neighbor_discovered; the guide named one that does not exist.ServiceIdalso refuses whitespace-only ids, ids over 256 bytes and the__prefix; the guide said only "non-empty".Licensing
THIRD-PARTY-NOTICES.mdis 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
test_services.py(22 tests),test_dnssd_bridge.py(79 tests) and the touchedtest_peer_stream_manager.pypass 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.dnssd_bridge.py,services.pyand thesidfilter inpeer_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.zeroconfblocked: both modules import, and only the responder's constructor names the missing extra.cargo fmt --all -- --check,cargo clippy -p offline-protocol-services -- -D warnings,cargo test -p offline-protocol-services --lib, rustdoc under-D warningsfor that crate, and the stream-framing chapter guard (cargo test -p offline-protocol-transport --test stream_framing_vectors) all pass.source: "lan"event with the right provider and capabilities, the resolved record had port 7878 and the publisher's own host name,peers_from_recordon 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
unregisterfor 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
(address, service_id)is described in the chapter and left to the application.Notes for reviewers
sidfilter exists for those responders.