Skip to content

Commit 2d4b61b

Browse files
committed
feat: reconcile a Default single-family instance to an explicit set
Instead of raising, moving a Default single-family instance to an explicit interface set now demotes its dual-use listen/responder socket to a pure listener and rebuilds it clean, then adds per-interface responders for the whole desired set. Rebuilding releases the dual-use socket's existing group memberships so the new joins do not collide (EADDRINUSE) when the desired set overlaps the interface it served, and demoting it first prevents double announcements.
1 parent 2058592 commit 2d4b61b

3 files changed

Lines changed: 91 additions & 35 deletions

File tree

src/zeroconf/_core.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,8 +448,9 @@ async def async_update_interfaces(
448448
``ip_version`` and ``apple_p2p`` each default to the value passed at
449449
construction; pass a new value to switch it. When the resulting
450450
interface set is unchanged this is a no-op (no sockets touched,
451-
nothing re-announced). The shared listen socket's family and unicast
452-
mode are fixed at construction. Concurrent calls are serialized.
451+
nothing re-announced). The listen socket is rebuilt if the new set
452+
needs a different address family; unicast mode is fixed at
453+
construction. Concurrent calls are serialized.
453454
"""
454455
# Resolve against the retained config but only commit it after the
455456
# engine reconcile succeeds, so a failed reconcile leaves the stored

src/zeroconf/_engine.py

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,10 @@ async def async_update_interfaces(
189189
190190
Adds a per-interface responder socket for each interface that
191191
appeared and tears down the socket for each interface that
192-
disappeared, diffing on the bound address. The shared listen
193-
socket (including the Default single-family dual-use socket) is
194-
never torn down here. Returns whether any responder socket was
192+
disappeared, diffing on the bound address. A Default single-family
193+
instance's dual-use listen/responder socket is converted to a pure
194+
listener when moving to an explicit set; otherwise the shared listen
195+
socket is left intact. Returns whether any responder socket was
195196
added, so the caller can skip re-announcing when nothing appeared.
196197
"""
197198
assert self.loop is not None
@@ -201,30 +202,35 @@ async def async_update_interfaces(
201202
listen_transport = self._listen_transport
202203
listen_socket = listen_transport.sock if listen_transport is not None else None
203204

204-
# A Default single-family instance shares the listen socket as its
205-
# only sender; adding per-interface senders alongside it would double
206-
# every announcement. Switching interface kind at runtime is not
207-
# supported, so raise (before any state changes) rather than
208-
# double-send. The no-arg refresh of a Default instance keys to the
209-
# listen socket and never reaches here.
205+
# The listen socket's family is fixed at construction, so a desired
206+
# interface of another family (e.g. an IPv6 interface added to an IPv4
207+
# instance) needs a fresh listen socket before senders are reconciled,
208+
# otherwise the current senders would be torn down with no replacements
209+
# bound.
210+
needs_rebuild = listen_socket is not None and any(
211+
not _listen_socket_supports(listen_socket, interface) for interface in desired.values()
212+
)
213+
214+
# A Default single-family instance shares the listen socket as its only
215+
# sender (the dual-use socket). Moving it to an explicit interface set
216+
# abandons that optimization: demote the socket so it stops responding
217+
# (otherwise it would double every announcement on the overlapping
218+
# interface) and rebuild it as a pure listener (its existing group
219+
# memberships would otherwise collide with the new per-interface joins).
220+
# Once demoted it no longer counts as a per-interface sender, so the
221+
# interface it served gets a fresh responder like any other. The no-arg
222+
# refresh of a Default instance leaves desired == {its interface} and so
223+
# neither demotes nor rebuilds.
210224
if listen_transport is not None and any(
211225
wrapped.transport is listen_transport.transport for wrapped in self.senders
212226
):
213227
listen_key = listen_transport.interface_key
214228
if any(key != listen_key for key in desired):
215-
raise RuntimeError(
216-
"Cannot change interfaces on a Default single-family Zeroconf instance; "
217-
"recreate it to use an explicit interface set"
218-
)
219-
220-
# The listen socket's family is fixed at construction. If a desired
221-
# interface cannot be joined on it (e.g. an IPv6 interface added to an
222-
# IPv4 instance), rebuild the listen socket for the new family before
223-
# reconciling senders, otherwise the current senders would be torn down
224-
# with no replacements bound.
225-
if listen_socket is not None and any(
226-
not _listen_socket_supports(listen_socket, interface) for interface in desired.values()
227-
):
229+
self.senders = [w for w in self.senders if w.transport is not listen_transport.transport]
230+
current = {wrapped.interface_key: wrapped for wrapped in self.senders}
231+
needs_rebuild = True
232+
233+
if needs_rebuild:
228234
await self._async_rebuild_listen_socket(apple_p2p, desired, current)
229235
listen_transport = self._listen_transport
230236
listen_socket = listen_transport.sock if listen_transport is not None else None
@@ -310,9 +316,9 @@ async def _async_rebuild_listen_socket(
310316
"""Replace the listen socket with one whose family covers the desired set.
311317
312318
The listen socket's family is otherwise fixed at construction; this
313-
lets an instance start receiving a newly added address family. Only
314-
called when a desired interface cannot be joined on the current listen
315-
socket. The replacement family is derived from the desired set (not the
319+
lets an instance start receiving a newly added address family, and is
320+
also used to convert a Default dual-use socket to a pure listener. The
321+
replacement family is derived from the desired set (not the
316322
requested ip_version, which an explicit list can contradict) so it
317323
always covers every desired interface and never needs an immediate
318324
re-rebuild. Interfaces that are staying are re-joined on the new socket,

tests/test_interface_update.py

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -432,22 +432,71 @@ async def test_update_interfaces_keeps_dual_use_listen_socket(aiozc_loopback: As
432432

433433

434434
@pytest.mark.asyncio
435-
async def test_update_interfaces_default_explicit_list_raises(aiozc_loopback: AsyncZeroconf) -> None:
436-
"""An explicit set on a dual-use instance raises before any state change or rebuild."""
435+
async def test_update_interfaces_default_to_explicit_reconciles(aiozc_loopback: AsyncZeroconf) -> None:
436+
"""Moving a dual-use instance to an explicit set demotes its socket and rebuilds clean."""
437437
engine = aiozc_loopback.zeroconf.engine
438438
await aiozc_loopback.zeroconf.async_wait_for_start()
439439
listen = engine._listen_transport
440440
assert listen is not None
441+
old_underlying = listen.transport
442+
# Simulate a Default single-family instance: the listen socket is the sole sender.
441443
engine.senders = [listen]
444+
new_listen_sock = Mock()
445+
new_listen_sock.family = socket.AF_INET
446+
447+
async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport:
448+
wrapped = _make_wrapped(("wrapped", 0), transport=Mock())
449+
(engine.senders if is_sender else engine.readers).append(wrapped)
450+
return wrapped
451+
442452
with (
443453
patch.object(_engine, "normalize_interface_choice", return_value=["192.168.1.5"]),
444-
patch.object(_engine, "new_listen_socket") as mock_new_listen,
445-
pytest.raises(RuntimeError, match="Default single-family"),
454+
patch.object(_engine, "new_listen_socket", return_value=new_listen_sock) as mock_new_listen,
455+
patch.object(_engine, "add_interface", return_value=Mock()),
456+
patch.object(_engine, "drop_multicast_member"),
457+
patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=fake_wrap)),
446458
):
447-
await engine.async_update_interfaces(["unused"], IPVersion.V4Only, False)
448-
assert engine.senders == [listen]
449-
# The dual-use guard takes precedence; it never falls through to a rebuild.
450-
mock_new_listen.assert_not_called()
459+
added = await engine.async_update_interfaces(["unused"], IPVersion.V4Only, False)
460+
461+
# The dual-use socket is rebuilt as a pure listener (demoted and closed),
462+
# a fresh listener replaces it, and the explicit interface gains a responder.
463+
assert added is True
464+
mock_new_listen.assert_called_once()
465+
assert engine._listen_transport is not listen
466+
assert listen not in engine.senders
467+
assert listen not in engine.readers
468+
assert old_underlying.is_closing()
469+
# One brand-new responder (for 192.168.1.5) is the only sender now.
470+
assert len(engine.senders) == 1
471+
assert engine.senders[0] is not listen
472+
473+
474+
@pytest.mark.asyncio
475+
async def test_update_interfaces_default_to_explicit_real(aiozc_loopback: AsyncZeroconf) -> None:
476+
"""A real dual-use socket with an overlapping membership reconciles without EADDRINUSE."""
477+
engine = aiozc_loopback.zeroconf.engine
478+
await aiozc_loopback.zeroconf.async_wait_for_start()
479+
listen = engine._listen_transport
480+
assert listen is not None
481+
assert listen.sock.family == socket.AF_INET
482+
old_underlying = listen.transport
483+
# Simulate a Default dual-use instance whose listen socket already joined
484+
# the loopback group, so a naive demote-and-rejoin would hit EADDRINUSE.
485+
_engine.add_multicast_member(listen.sock, "127.0.0.1")
486+
engine.senders = [listen]
487+
488+
with patch.object(_engine, "normalize_interface_choice", return_value=["127.0.0.1"]):
489+
added = await engine.async_update_interfaces(["unused"], IPVersion.V4Only, False)
490+
491+
assert added is True
492+
new_listen = engine._listen_transport
493+
assert new_listen is not None
494+
assert new_listen is not listen
495+
assert new_listen.sock.family == socket.AF_INET
496+
assert old_underlying.is_closing()
497+
# The overlapping interface got a real responder on the fresh listen socket.
498+
assert len(engine.senders) == 1
499+
assert engine.senders[0] is not listen
451500

452501

453502
@pytest.mark.asyncio

0 commit comments

Comments
 (0)