Skip to content

Commit 07b7927

Browse files
committed
cog-ha-matter (ADR-116 P4): live mDNS responder + handle
Closes the mDNS half of P4. `runtime::start_mdns_responder` binds multicast via `mdns_sd::ServiceDaemon::new`, builds the ServiceInfo from `MdnsService::to_service_info` (iter 9), and registers — returning a typed handle that owns both daemon and fullname. Handle shape: pub struct MdnsResponderHandle { daemon: ServiceDaemon, fullname: String, } impl MdnsResponderHandle { pub fn fullname(&self) -> &str; pub fn shutdown(self) -> Result<(), mdns_sd::Error>; } impl Drop for MdnsResponderHandle { /* best-effort */ } Why explicit `shutdown` + best-effort `Drop`: a clean shutdown sends a goodbye packet so HA's discovery integration sees the service leave (good UX — no stale device card). `Drop` is the fallback for panics / process termination but swallows errors since panicking-in-Drop would mask the real failure. 1 new live-I/O test: * mdns_responder_fullname_concatenates_instance_and_service_type — actually binds multicast on the loopback adapter, registers, asserts the fullname contains `_ruview-ha._tcp`, then shutdown()s. Confirmed working on Windows; CI environments where multicast bind is filtered will hit the gracefully- skipping early return rather than failing the suite. 64/64 cog tests green (63 → 64). ADR-116 P4: mDNS half ✅ (record-builder + ServiceInfo + live responder), witness half ✅ (chain + JSONL + file + Ed25519). Last piece is the embedded rumqttd broker so external mosquitto becomes optional. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 34eced8 commit 07b7927

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

docs/adr/ADR-116-cog-ha-matter-seed.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ Ranked by build cost × user impact:
9595
| **P1** | Research dossier ([`docs/research/ADR-116-ha-matter-cog-research.md`](../research/ADR-116-ha-matter-cog-research.md)) |**done** — 8 sections, 30+ citations, v1 scope ranked |
9696
| **P2** | Cog crate scaffold (`v2/crates/cog-ha-matter/`) — Cargo.toml + `src/{lib,main,manifest}.rs`, workspace member, CLI args, `--print-manifest` flag, 2 manifest unit tests |**done**`cargo check` + `cargo test` green |
9797
| **P3** | Wrap existing ADR-115 MQTT publisher as cog entry point |**wiring done**`main.rs` boots ADR-115's `publisher::spawn` via `runtime::spawn_publisher` thin wrapper, holds a long-lived `broadcast::Sender<VitalsSnapshot>`, awaits Ctrl-C. Live-handle test green without a broker. Next (P3.5): subscribe to sensing-server `/v1/snapshot` WS and republish into the channel. |
98-
| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | in progress — (a) mDNS record-builder ✅. (b) Witness hash-chain ✅. (c) JSONL line serializer ✅. (d) File persistence + chain-level verify ✅. (e) Ed25519 signing layer ✅. **(f) mDNS ServiceInfo conversion ✅** `MdnsService::to_service_info(hostname, ipv4)` produces the `mdns_sd::ServiceInfo` the responder daemon consumes; 3 tests verify service-type, port, TXT propagation. `mdns-sd = 0.11` aligned with the workspace's existing pin from `wifi-densepose-desktop`. (g) `ServiceDaemon::register` spawn + embedded rumqttd still pending — the remaining live-I/O pieces before P4 flips ✅. |
98+
| **P4** | Seed-native enhancements (embedded broker, mDNS, witness) | in progress — **mDNS half complete:** record-builder ✅, ServiceInfo conversion ✅, **live responder ✅** (`runtime::start_mdns_responder` binds multicast, registers, returns `MdnsResponderHandle` with explicit `shutdown()` + best-effort Drop). **Witness half complete:** hash-chain ✅, JSONL line serializer ✅, file persistence + chain-level verify ✅, Ed25519 signing ✅. **Remaining:** embedded rumqttd broker. |
9999
| **P5** | RuVector-backed threshold learning (SONA adaptation) | pending |
100100
| **P6** | Multi-Seed federation (cross-Seed dedup + witness) | pending |
101101
| **P7** | Matter Bridge mode (depends on matter-rs / esp-matter readiness) | pending |

v2/crates/cog-ha-matter/src/runtime.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
2222
use std::sync::Arc;
2323

24+
use mdns_sd::ServiceDaemon;
2425
use tokio::{sync::broadcast, task::JoinHandle};
2526
use wifi_densepose_sensing_server::mqtt::{
2627
config::{MqttConfig, PublishRates, TlsConfig},
@@ -29,6 +30,8 @@ use wifi_densepose_sensing_server::mqtt::{
2930
DEFAULT_DISCOVERY_PREFIX, MANUFACTURER,
3031
};
3132

33+
use crate::mdns::MdnsService;
34+
3235
/// Caller-supplied identity for the cog instance. Filled in by the
3336
/// cog runtime from the mDNS hostname / Seed control plane in
3437
/// production; threaded as a parameter so tests can build inputs
@@ -129,6 +132,66 @@ pub fn spawn_publisher(
129132
publisher::spawn(Arc::new(config), discovery, state_rx)
130133
}
131134

135+
/// Owned handle to a live mDNS responder. Holding it keeps the
136+
/// service advertised; `shutdown` unregisters cleanly so HA's
137+
/// discovery integration sees a goodbye packet instead of a
138+
/// dropped advertisement.
139+
///
140+
/// `Drop` is best-effort: tries unregister + daemon shutdown but
141+
/// swallows errors, since panicking in Drop would mask the real
142+
/// failure that prompted the shutdown.
143+
pub struct MdnsResponderHandle {
144+
daemon: ServiceDaemon,
145+
fullname: String,
146+
}
147+
148+
impl MdnsResponderHandle {
149+
/// Fully-qualified DNS-SD name (`<instance>.<type>.<domain>`).
150+
/// Exposed for tests + logging; the responder uses it to
151+
/// unregister.
152+
pub fn fullname(&self) -> &str {
153+
&self.fullname
154+
}
155+
156+
/// Unregister the service and shut down the daemon. Returns
157+
/// any error so the caller's shutdown sequence can surface it.
158+
pub fn shutdown(self) -> Result<(), mdns_sd::Error> {
159+
let _ = self.daemon.unregister(&self.fullname);
160+
let _ = self.daemon.shutdown()?;
161+
Ok(())
162+
}
163+
}
164+
165+
impl Drop for MdnsResponderHandle {
166+
fn drop(&mut self) {
167+
let _ = self.daemon.unregister(&self.fullname);
168+
let _ = self.daemon.shutdown();
169+
}
170+
}
171+
172+
/// Start the mDNS responder for a cog and register its service.
173+
///
174+
/// Binds a multicast socket (`mdns_sd::ServiceDaemon::new`) and
175+
/// publishes `service` under `hostname` (must end in `.local.`)
176+
/// and `ipv4` (the LAN-routable address HA's discovery reaches
177+
/// back on).
178+
///
179+
/// Live-I/O: binding multicast may fail in containerised CI or
180+
/// on networks where 5353/udp is filtered — callers should treat
181+
/// the error as recoverable (log + retry, or fall back to manual
182+
/// HA configuration) rather than fatal to the cog.
183+
pub fn start_mdns_responder(
184+
service: &MdnsService,
185+
hostname: &str,
186+
ipv4: &str,
187+
) -> Result<MdnsResponderHandle, mdns_sd::Error> {
188+
let daemon = ServiceDaemon::new()?;
189+
let info = service.to_service_info(hostname, ipv4)?;
190+
let fullname = info.get_fullname().to_string();
191+
daemon.register(info)?;
192+
Ok(MdnsResponderHandle { daemon, fullname })
193+
}
194+
132195
#[cfg(test)]
133196
mod tests {
134197
use super::*;
@@ -230,6 +293,36 @@ mod tests {
230293
assert!(DEFAULT_STATE_CHANNEL_CAPACITY >= 64);
231294
}
232295

296+
#[test]
297+
fn mdns_responder_fullname_concatenates_instance_and_service_type() {
298+
// Live-I/O test: binds multicast on the loopback adapter.
299+
// Skips with a warning if the host's network stack refuses
300+
// the bind (containerised CI without --network host, etc.)
301+
// rather than failing the whole test suite.
302+
use crate::mdns::build_mdns_service;
303+
let svc = build_mdns_service(&id(), 9180, 1883, false);
304+
let handle = match start_mdns_responder(&svc, "cog-ha-matter-test.local.", "127.0.0.1") {
305+
Ok(h) => h,
306+
Err(e) => {
307+
eprintln!("mdns multicast bind not available in this sandbox: {e} — skipping");
308+
return;
309+
}
310+
};
311+
// Fullname format is "<instance>.<service_type>." per RFC 6763.
312+
// mdns-sd may URL-escape special chars (— in instance name) so
313+
// we only assert on the service-type segment which is stable.
314+
let fullname = handle.fullname().to_string();
315+
assert!(
316+
!fullname.is_empty(),
317+
"fullname empty after register"
318+
);
319+
assert!(
320+
fullname.contains("_ruview-ha._tcp"),
321+
"fullname `{fullname}` missing service type"
322+
);
323+
handle.shutdown().expect("clean shutdown");
324+
}
325+
233326
#[test]
234327
fn default_identity_carries_pkg_version_and_pid() {
235328
let identity = CogIdentity::default_for_build();

0 commit comments

Comments
 (0)