Skip to content

Add native DNS-SD advertiser for macOS (no UDP 5353 bind) - #213

Open
kgoderis wants to merge 3 commits into
hap-java:masterfrom
kgoderis:feature/native-dnssd-advertiser
Open

kgoderis wants to merge 3 commits into
hap-java:masterfrom
kgoderis:feature/native-dnssd-advertiser

Conversation

@kgoderis

@kgoderis kgoderis commented Oct 22, 2026 •

Copy link
Copy Markdown

What

Adds a pluggable HomekitAdvertiser and a native DNS-SD / Bonjour implementation (DnssdHomekitAdvertiser) that registers _hap._tcp via DNSServiceRegister (JNA) without binding UDP 5353. Existing JmDNS constructors keep working.

Factories on HomekitAdvertisers:

  • jmdns(JmDNS) / jmdns(InetAddress) — force JmDNS (e.g. share openHAB’s instance)
  • nativeDnssd() — native on all interfaces
  • nativeDnssd(InetAddress) — native scoped to that LAN address’s interface index + SRV host
  • isNativeDnssdSupported() — library load probe only
  • auto(...) — deprecated; policy belongs in the application (openHAB)

See docs/NATIVE_DNSSD.md.

Why

Fixes #212. On some macOS setups (exclusive mDNSResponder on UDP 5353), a dedicated JmDNS advertiser fails with “Address already in use” / “no suitable mDNS” and Home never discovers the accessory. Other Macs share 5353 via SO_REUSEPORT, so JmDNS can still work there — integrators should choose explicitly.

Compatibility

  • Additive API: new HomekitServer / HomekitRoot overloads that take HomekitAdvertiser.
  • Existing InetAddress / JmDNS constructors unchanged.
  • JNA is optional (<optional>true</optional>). JmDNS-only consumers do not pull it transitively. Callers of nativeDnssd() must declare net.java.dev.jna:jna (openHAB already embeds it).

Review follow-ups

  • Optional JNA; LinkageError handled in isSupported()
  • Configurable DNS-SD interfaceIndex / host via nativeDnssd(InetAddress)
  • Explicit advertiser choice; auto() deprecated
  • Removed non-running DnssdLiveAdvertiserIT and tautological HomekitAdvertisersTest
  • Full HAP TXT / JmDNS property assertions (stable LinkedHashMap key order; RFC 6763 order-independent)

Testing

  • mvn test on JDK (local): unit suite green
  • Mocked DNSSD register / interface+host / re-register / stop
  • Manual macOS check: dns-sd -B / -L after nativeDnssd(bind); no Java UDP 5353 socket

Checklist

@kgoderis
kgoderis force-pushed the feature/native-dnssd-advertiser branch from 175f81a to fb1842f Compare October 22, 2026 19:12
Pluggable HomekitAdvertiser lets callers use DnssdHomekitAdvertiser (JNA
DNSServiceRegister) instead of JmDNS when mDNSResponder owns UDP 5353.
Fixes hap-java#212.
@kgoderis

Copy link
Copy Markdown
Author

@yfre Eugen, this PR is necessary for a downstream fix in openHab openhab/openhab-addons#22013

@yfre

yfre commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

@kgoderis how to reproduce the issue?
i tried on my mac

  • mac mini
  • osx- golden gate
  • java 11 and 21

it does work without this PR. both Java HAP and mDNSResponder are bounded to the same port without any issues and i see Java HAP in mdns

int err =
dnsSd.DNSServiceRegister(
refOut,
0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 means all interfaces, e.g. on my machine it was bounded to 13(!) interface including 127.0.0.1, ::1, and some virtual one. do we want to limit to one address / host or make it configurable? see also line 162 (host = null)

Comment on lines +72 to +76
public static HomekitAdvertiser auto(InetAddress localAddress) throws IOException {
if (DnssdHomekitAdvertiser.isSupported()) {
return new DnssdHomekitAdvertiser();
}
return new JmdnsHomekitAdvertiser(localAddress);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would prefer to able to force jmDNS usage, even on linux or macosx library. i.e. i could use share jmDNS instance from openHab. it should be configurable in openHAB and not automatic decision in Java HAP.
in addition, we only check whether library can be loaded but not whether it works.

Comment thread pom.xml
</dependency>

<!-- Native Bonjour / DNS-SD (macOS libSystem, Linux libdns_sd) -->
<dependency>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes JNA (~1.9 MB, native access) a compile-scope transitive dependency for every HAP-Java consumer, including everyone who only uses the JmDNS path. mvn dependency:tree shows net.java.dev.jna:jna:jar:5.14.0:compile.

Since native DNS-SD is opt-in, can this be optional? Consumers who want the native advertiser then declare JNA themselves. openhab-addons#22013 already embeds JNA in the bundle, so nothing changes for openHAB.
i.e.

net.java.dev.jna
jna
5.14.0
true

try {
DnsSdLibrary.load();
return true;
} catch (UnsatisfiedLinkError | Exception e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When JNA isn’t on the classpath, DnsSdLibrary.load() fails with NoClassDefFoundError: com/sun/jna/Library, which this catch doesn’t handle. I tested it: HomekitAdvertisers.isNativeDnssdSupported() throws instead of returning false.

Catching LinkageError covers both a missing JNA and a missing native library.

} catch (LinkageError | Exception e) {

* Optional live registration against the host DNS-SD daemon (macOS mDNSResponder). Skipped when
* native DNS-SD is unavailable. Run with {@code mvn test} on macOS to exercise Bonjour IPC.
*/
public class DnssdLiveAdvertiserIT {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test never runs. Surefire only picks up Test/Test/*Tests/*TestCase, and the pom has no failsafe plugin. It’s missing from target/surefire-reports after mvn test. Even if it ran, line 24 only checks isSupported() right after assumeTrue(isSupported()), so it can’t fail. It doesn’t confirm the service was registered or can be resolved.

CI runs on ubuntu-latest without libdns_sd, so even a working version would be skipped there. I’d suggest removing it and describing the manual macOS check (dns-sd -B / -L) in the PR description instead.

public class HomekitAdvertisersTest {

@Test
public void isNativeDnssdSupportedMatchesAdvertiserDetection() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isNativeDnssdSupported() just returns DnssdHomekitAdvertiser.isSupported(), so this test compares a value with itself. If HomekitAdvertisers is trimmed down, this test can go too.

String setupId,
int configurationIndex,
int category) {
Map<String, String> props = new LinkedHashMap<>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the JmDNS properties from HashMap to LinkedHashMap changes the TXT record order that existing users publish. On master dns-sd -L shows c#=1 s#=1 ff=0 sf=1 sh=… ci=2 pv=1.1 md=… id=…, and with this PR it’s insertion order. RFC 6763 says order doesn’t matter, so this is probably harmless. But it changes the existing JmDNS path, and JmdnsHomekitAdvertiserTest only checks sf and c#. Could you add a test that checks the full JmDNS property set is unchanged?

@kgoderis

kgoderis commented Nov 3, 2026

Copy link
Copy Markdown
Author

@yfre Thanks — useful that it works on your Mini. That difference is real, and it comes down to how UDP 5353 is bound on macOS, not to HAP-Java “always” failing.

Same port, two processes — how?

For normal unicast TCP/UDP, yes: one listener per (address, port). mDNS is different: it’s UDP multicast on 5353, and on BSD/macOS sockets can set SO_REUSEPORT so several processes bind *:5353 at once and all see the multicast traffic.

So when you write that Java HAP and mDNSResponder are both bound to the same port, that usually means 5353 is in shared / reuse mode. That’s valid; it isn’t the classic exclusive TCP listen model.

What Apple does in practice varies by boot/bind order:

Mode What you see JmDNS dedicated advertiser
Shared (SO_REUSEPORT) mDNSResponder and Java both on 5353 Can work (your case)
Exclusive Only mDNSResponder on 5353 Address already in use / no _hap._tcp (our case)

mDNSResponder typically binds first at boot. If that first bind is exclusive, later JmDNS binds fail. There is a known fragile way to force share (stop mDNSResponder → bind 5353 yourself with SO_REUSEPORT → restart mDNSResponder), but it needs root, resets on reboot, and can upset Bonjour/AirPlay — not something we want HAP-Java or openHAB to depend on. Details: SO answer on macOS mDNS bind.

How we reproduced #212

On a native macOS openHAB host (Mac mini M2), not a setup where 5353 was already shared:

  1. sudo lsof -nP -iUDP:5353 showed only mDNSResponder on *:5353 (IPv4 + IPv6) — no Java process.
  2. dns-sd -B _openhab-server._tcp stayed empty → openHAB’s own JmDNS never got 5353 either.
  3. HomeKit with useOHmDNS=true logged roughly:
    • no suitable mDNS server for IP /192.168.0.6 found
    • create HomeKit server with dedicated mDNS server
    • could not initialize HomeKit bridge: Address already in use
  4. HAP TCP ports never came up; dns-sd -B _hap._tcp stayed empty.

So we always took the dedicated JmDNS path against an exclusive mDNSResponder bind. Your Golden Gate Mini likely has 5353 in share mode (or otherwise allows a second bind), which is why HAP-Java works there without this PR — that doesn’t mean the exclusive-bind failure mode isn’t real.

Why this PR still helps

Native DNSServiceRegister (Bonjour IPC) advertises _hap._tcp without binding UDP 5353 at all, so it works whether mDNSResponder is exclusive or shared. After the change we could browse the service with dns-sd -B / -L while the JVM held no 5353 socket.

Happy to adjust the PR for your other review notes (optional JNA, LinkageError, configurable advertiser / interface, trim the non-running IT, etc.).

Make JNA optional, catch LinkageError when it is absent, let callers force
JmDNS or pass InetAddress to scope DNS-SD interface/host, deprecate auto(),
and replace the non-running live IT with fuller TXT/JmDNS unit coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kgoderis

kgoderis commented Nov 3, 2026

Copy link
Copy Markdown
Author

Pushed a follow-up commit addressing the review comments:

  • JNA marked <optional>true</optional> so JmDNS-only consumers do not get it transitively.
  • isSupported() catches LinkageError (missing JNA / native lib) and returns false.
  • Interface/host: nativeDnssd(InetAddress) (and constructors) set DNS-SD interfaceIndex + SRV host instead of always using 0 / null.
  • No automatic policy in HAP: prefer jmdns(...) or nativeDnssd(...); auto(...) is deprecated. openHAB (or other integrators) choose which advertiser to use — including forcing shared JmDNS on macOS/Linux.
  • Removed DnssdLiveAdvertiserIT (never ran under Surefire; CI has no Bonjour) and the tautological HomekitAdvertisersTest. Manual dns-sd -B / -L steps are in docs/NATIVE_DNSSD.md.
  • HapDnsTxt / JmDNS tests now assert the full property set and stable key insertion order (RFC 6763 still treats pair order as insignificant).

PR description updated accordingly.

@yfre

yfre commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

thank you @kgoderis for making adjustments.
but does it work on your side with the latest commit?

I tested locally, just hap-java without openhab, mac mini m4 pro (golden gate). without this PR I can pair the bridge in the home app no problem. with this PR using nativeDnssd(InetAddress.getByName("192.168.1.101")) (same address the hap server binds to, like in the readme), the qr code gets accepted but then it just hangs on "Setting up", phone never even connects to the hap port. when I pass host = null instead it pairs right away, same code, same interface.

I think it's the host. DnssdHomekitAdvertiser.java:128-130 uses localAddress.getHostAddress() as SRV host (line 244), so the target ends up as the name 192.168.1.101. and there's no A record behind it. dns-sd -Z _hap._tcp local. shows the SRV pointing to 192.168.1.101., and dns-sd -G v4 192.168.1.101. gives "No Such Record". dns-sd -L looks ok which is a bit misleading. dns_sd.h also says a non-null host doesn't register the address for you.

shared port works fine here too btw. mDNSResponder always sets SO_REUSEPORT on 5353 (SetupSocket in mDNSMacOSX.c), and java does as well, tried 11, 21 and 27. you can see it in netstat -anv -p udp | grep '\.5353 ', mDNSResponder has 0x00050200 and java 0x224 here. only way I got "Address already in use" was binding without reuse.

you also said openhab's own jmdns couldn't get 5353 either, so I'd rather think something's off on that machine. can you post that netstat output, the full stacktrace (openhab only logs the message) and your macos + jdk version?

please test on your side and let me know.

Keep DNS-SD host null so mDNSResponder publishes the system hostname
with A/AAAA; only scope registration by interface index. A literal IP as
host became an SRV target with no address record.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kgoderis

kgoderis commented Nov 3, 2026

Copy link
Copy Markdown
Author

Fixed the SRV-host pairing hang you reported.

Cause: nativeDnssd(InetAddress) passed localAddress.getHostAddress() as DNS-SD host, so SRV pointed at 192.168.x.x. with no A record (dns-sd -G → No Such Record). host = null was fine.

Fix: nativeDnssd(InetAddress) / new DnssdHomekitAdvertiser(InetAddress) now only set interfaceIndex from the matching NetworkInterface and leave host = null (system hostname + A/AAAA). Docs/CHANGES updated accordingly.

Verified locally: nativeDnssd(lan) → host=null, SRV targets the .local hostname again.

@kgoderis

kgoderis commented Nov 3, 2026

Copy link
Copy Markdown
Author

Thanks @yfre — you were right about the host.

I reproduced it here: with nativeDnssd(InetAddress) the old code put getHostAddress() into DNS-SD host, so dns-sd -Z showed SRV → 192.168.x.x. and dns-sd -G v4 192.168.x.x. → No Such Record. host = null was fine (SRV → *.local. with a real A). That matches what you saw (QR accepted, then hang on “Setting up”).

Fixed in the latest commit: nativeDnssd(InetAddress) now only scopes by interface index and keeps host = null (system hostname + A/AAAA). Docs updated so we don’t suggest an IP as SRV host. Please retry pairing with that build.


On the 5353 / openHAB side, from Karel’s Mac mini M2:

  • macOS 27.0.1 (26A434)
  • openHAB JDK Zulu 21.0.6 (21.0.6+7-LTS); also have Zulu 17.0.7 installed

netstat -anv -p udp | grep '\.5353 ':

udp6  *.5353  mDNSResponder:724  ... 00050200 ...
udp4  *.5353  mDNSResponder:724  ... 00050200 ...

Same 0x00050200 reuse flags you described — so yes, mDNSResponder is in share mode here too. Bind without reuse → EADDRINUSE; bind with SO_REUSEPORT → OK. I was wrong earlier to frame this as a permanently exclusive 5353 on this machine.

Where it still fails for us is JmDNS as used by openHAB. Inside the live OH JVM (and standalone with OH’s jar):

JmDNS.create(InetAddress.getByName("192.168.0.6")) with JmDNS 3.6.4 (openHAB runtime):

java.net.BindException: Address already in use
	at java.base/sun.nio.ch.Net.bind0(Native Method)
	at java.base/sun.nio.ch.Net.bind(Net.java:565)
	at java.base/sun.nio.ch.DatagramChannelImpl.bindInternal(DatagramChannelImpl.java:1329)
	at java.base/sun.nio.ch.DatagramChannelImpl.bind(DatagramChannelImpl.java:1299)
	at java.base/sun.nio.ch.DatagramSocketAdaptor.bind(DatagramSocketAdaptor.java:108)
	at java.base/java.net.DatagramSocket.createDelegate(DatagramSocket.java:1440)
	at java.base/java.net.MulticastSocket.<init>(MulticastSocket.java:211)
	at javax.jmdns.impl.JmDNSImpl.openMulticastSocket(JmDNSImpl.java:528)
	at javax.jmdns.impl.JmDNSImpl.<init>(JmDNSImpl.java:468)
	at javax.jmdns.impl.JmDNSImpl.<init>(JmDNSImpl.java:427)
	at javax.jmdns.JmDNS.create(JmDNS.java:105)

Same machine, JmDNS 3.5.6 create(192.168.0.6) → OK. So the pain on this Mini looks more like 3.6.4’s multicast bind vs mDNSResponder, not “reuse never works”. That lines up with openHAB’s “no suitable mDNS” / dedicated JmDNS path blowing up while Bonjour native register works.

Live openHAB on this Mini with the native advertiser (nativeDnssd(), host=null): all instances register, dns-sd -L openHAB → Karels-Mac-mini-M2.local.:9124, A resolves, TCP open, no Java socket on 5353.

Happy to tweak further if you still see issues after the host=null fix.

@yfre

yfre commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

I tested the JmDNS versions and can confirm, the issue exists with JmDNS 3.6.4 only, which was in the openHAB snapshot for a few weeks in October. With the current snapshot (JmDNS 3.6.5) everything works without this PR on my mac.

so the remaining question is more general: do we want native mDNS support? and if yes, wouldn't openHAB be a better place for it, since openHAB core has its own mdns service used by all bindings?

@ccutrer what do you think?

@kgoderis thanks a lot for your time on this and for the quick fixes. with the latest commit the native path works on my mac. there are still a few smaller things, but let's first agree on the main question before going into those.

@kgoderis

kgoderis commented Nov 4, 2026

Copy link
Copy Markdown
Author

My take:

1. openHAB platform mDNS → openHAB core
If the goal is “macOS / Bonjour works for the whole runtime,” that belongs in openHAB’s shared mDNS service, not in HAP-Java. Core already owns the JmDNS lifecycle every binding (including HomeKit via useOHmDNS) is supposed to share. A native DNS-SD backend there would help HomeKit and everything else, with one policy knob — not a HomeKit-only side path.

2. HAP-Java → advertiser SPI (+ default JmDNS), not OH’s mDNS policy
HAP still has to advertise _hap._tcp when used standalone (no openHAB). The useful part of this PR for the library is the pluggable HomekitAdvertiser so an embedder can inject whatever the host provides. A native DnssdHomekitAdvertiser in HAP is optional convenience for non-OH embeds / dedicated-socket setups; it should not be where openHAB solves fleet mDNS.

3. Practical split

Concern Where
Shared mDNS for all bindings, JmDNS version, future native Bonjour for the runtime openHAB core (MDNSService / equivalent)
HomeKit chooses shared OH mDNS vs dedicated advertiser openHAB HomeKit (useOHmDNS, inject advertiser)
HAP advertise API + JmDNS default; optional native for standalone HAP-Java

So: yes to native as a platform capability in openHAB if we still want it after 3.6.5; no as the primary fix living only in HAP-Java. I’m fine reshaping this PR toward a thin SPI (and keeping or dropping the native impl per what you and @ccutrer prefer), and putting any OH-wide Bonjour work on the core side instead.

Happy to wait on that call before the smaller leftover nits.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

macOS: JmDNS advertiser cannot bind UDP 5353 (mDNSResponder); need native DNS-SD path

2 participants