Skip to content

Commit d05d3e2

Browse files
committed
fix: rebuild listen socket safely - derive family from desired set, clean up on wrap failure, defend v6only read
1 parent 3dae38b commit d05d3e2

2 files changed

Lines changed: 124 additions & 11 deletions

File tree

src/zeroconf/_engine.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,13 @@ def _listen_socket_supports(
7474
if listen_socket.family != socket.AF_INET6:
7575
# An IPv4 interface on an AF_INET socket.
7676
return True
77-
# An IPv4 interface on an AF_INET6 socket: only when it is dual-stack.
78-
return not listen_socket.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY)
77+
# An IPv4 interface on an AF_INET6 socket: only when it is dual-stack. If
78+
# the option can't be read (as Windows rejects some IPv6 getsockopts),
79+
# assume supported so a read failure can't drive a rebuild loop.
80+
try:
81+
return not listen_socket.getsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY)
82+
except OSError:
83+
return True
7984

8085

8186
class AsyncEngine:
@@ -220,7 +225,7 @@ async def async_update_interfaces(
220225
if listen_socket is not None and any(
221226
not _listen_socket_supports(listen_socket, interface) for interface in desired.values()
222227
):
223-
listen_socket = await self._async_rebuild_listen_socket(ip_version, apple_p2p, desired, current)
228+
listen_socket = await self._async_rebuild_listen_socket(apple_p2p, desired, current)
224229
listen_transport = self._listen_transport
225230

226231
for bind_address, wrapped in current.items():
@@ -302,7 +307,6 @@ def _async_close_sender(self, wrapped: _WrappedTransport, listen_socket: socket.
302307

303308
async def _async_rebuild_listen_socket(
304309
self,
305-
ip_version: IPVersion,
306310
apple_p2p: bool,
307311
desired: dict[tuple[str, int], str | tuple[tuple[str, int, int], int]],
308312
current: dict[tuple[str, int], _WrappedTransport],
@@ -312,20 +316,38 @@ async def _async_rebuild_listen_socket(
312316
The listen socket's family is otherwise fixed at construction; this
313317
lets an instance start receiving a newly added address family. Only
314318
called when a desired interface cannot be joined on the current listen
315-
socket. Interfaces that are staying are re-joined on the new socket,
319+
socket. The replacement family is derived from the desired set (not the
320+
requested ip_version, which an explicit list can contradict) so it
321+
always covers every desired interface and never needs an immediate
322+
re-rebuild. Interfaces that are staying are re-joined on the new socket,
316323
and the old socket is closed (releasing its memberships).
317324
"""
318-
new_listen = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p)
325+
has_v6 = any(isinstance(interface, tuple) for interface in desired.values())
326+
has_v4 = any(not isinstance(interface, tuple) for interface in desired.values())
327+
if has_v4 and has_v6:
328+
family_version = IPVersion.All
329+
elif has_v6:
330+
family_version = IPVersion.V6Only
331+
else:
332+
family_version = IPVersion.V4Only
333+
new_listen = new_socket(bind_addr=("",), ip_version=family_version, apple_p2p=apple_p2p)
319334
if new_listen is None:
320-
raise RuntimeError("Failed to create a listen socket for the requested interface family")
321-
for bind_address, interface in desired.items():
322-
if bind_address in current:
323-
add_multicast_member(new_listen, interface)
335+
raise RuntimeError("Failed to create a listen socket for the new interface family")
336+
try:
337+
for bind_address, interface in desired.items():
338+
if bind_address in current:
339+
add_multicast_member(new_listen, interface)
340+
new_reader = await self._async_wrap_socket(new_listen, is_sender=False)
341+
except Exception:
342+
# Endpoint creation failed; close the unadopted socket (and its
343+
# joins) rather than leak it, mirroring _async_add_interface.
344+
new_listen.close()
345+
raise
324346
# A rebuild is only entered with a live listen socket, so the old
325347
# transport is always present.
326348
old_listen_transport = self._listen_transport
327349
assert old_listen_transport is not None
328-
self._listen_transport = await self._async_wrap_socket(new_listen, is_sender=False)
350+
self._listen_transport = new_reader
329351
old_transport = old_listen_transport.transport
330352
self._async_remove_transport(old_transport)
331353
old_transport.close()

tests/test_interface_update.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ def test_listen_socket_supports_family() -> None:
108108
assert _listen_socket_supports(v6_sock, "1.2.3.4") is True
109109
v6_sock.getsockopt.return_value = 1 # IPV6_V6ONLY on -> v6-only
110110
assert _listen_socket_supports(v6_sock, "1.2.3.4") is False
111+
# An unreadable option (some platforms) is treated as supported so it
112+
# can't drive a rebuild loop.
113+
v6_sock.getsockopt.side_effect = OSError
114+
assert _listen_socket_supports(v6_sock, "1.2.3.4") is True
111115

112116

113117
@pytest.mark.asyncio
@@ -544,6 +548,93 @@ async def test_update_interfaces_rebuild_failure_raises(aiozc_loopback: AsyncZer
544548
await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False)
545549

546550

551+
@pytest.mark.asyncio
552+
async def test_update_interfaces_rebuild_closes_socket_on_wrap_failure(
553+
aiozc_loopback: AsyncZeroconf,
554+
) -> None:
555+
"""If wrapping the new listen socket fails, it is closed rather than leaked."""
556+
engine = aiozc_loopback.zeroconf.engine
557+
await aiozc_loopback.zeroconf.async_wait_for_start()
558+
old_listen = engine._listen_transport
559+
new_listen_sock = Mock()
560+
new_listen_sock.family = socket.AF_INET6
561+
562+
with (
563+
patch.object(_engine, "normalize_interface_choice", return_value=[(("fe80::1", 0, 0), 1)]),
564+
patch.object(_engine, "new_socket", return_value=new_listen_sock),
565+
patch.object(_engine, "add_multicast_member", return_value=True),
566+
patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=OSError("boom"))),
567+
pytest.raises(OSError),
568+
):
569+
await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False)
570+
571+
# The unadopted socket was closed, and the old listen socket is untouched.
572+
new_listen_sock.close.assert_called_once()
573+
assert engine._listen_transport is old_listen
574+
575+
576+
@pytest.mark.asyncio
577+
async def test_update_interfaces_rebuild_family_matches_desired_set(
578+
aiozc_loopback: AsyncZeroconf,
579+
) -> None:
580+
"""The rebuilt listen socket's family is derived from the desired set, not ip_version."""
581+
engine = aiozc_loopback.zeroconf.engine
582+
await aiozc_loopback.zeroconf.async_wait_for_start()
583+
new_listen_sock = Mock()
584+
new_listen_sock.family = socket.AF_INET
585+
586+
async def fake_wrap(sock: object, is_sender: bool) -> _WrappedTransport:
587+
wrapped = _make_wrapped(("wrapped", 0), transport=Mock())
588+
(engine.senders if is_sender else engine.readers).append(wrapped)
589+
return wrapped
590+
591+
with (
592+
patch.object(_engine, "normalize_interface_choice", return_value=["192.168.1.5"]),
593+
patch.object(_engine, "_listen_socket_supports", return_value=False), # force a rebuild
594+
patch.object(_engine, "new_socket", return_value=new_listen_sock) as mock_new_socket,
595+
patch.object(_engine, "add_multicast_member", return_value=True),
596+
patch.object(_engine, "new_respond_socket", return_value=Mock()),
597+
patch.object(_engine, "drop_multicast_member"),
598+
patch.object(_engine.AsyncEngine, "_async_wrap_socket", new=AsyncMock(side_effect=fake_wrap)),
599+
):
600+
# ip_version says V6Only, but the desired set is all IPv4, so the
601+
# rebuilt socket is IPv4 (covers the set; no immediate re-rebuild).
602+
await engine.async_update_interfaces(["unused"], IPVersion.V6Only, False)
603+
604+
mock_new_socket.assert_called_once()
605+
assert mock_new_socket.call_args.kwargs["ip_version"] is IPVersion.V4Only
606+
607+
608+
@pytest.mark.asyncio
609+
async def test_update_interfaces_rebuilds_real_listen_socket(aiozc_loopback: AsyncZeroconf) -> None:
610+
"""End to end: a family change builds a real dual-stack listen socket and closes the old one."""
611+
engine = aiozc_loopback.zeroconf.engine
612+
await aiozc_loopback.zeroconf.async_wait_for_start()
613+
old_listen = engine._listen_transport
614+
assert old_listen is not None
615+
assert old_listen.sock.family == socket.AF_INET # V4Only loopback instance
616+
old_underlying = old_listen.transport
617+
618+
v6 = (("fe80::1", 0, 0), 1)
619+
# Real new_socket + _async_wrap_socket run; only membership joins and the
620+
# (unbindable) v6 responder are stubbed so no real multicast is exercised.
621+
with (
622+
patch.object(_engine, "normalize_interface_choice", return_value=["127.0.0.1", v6]),
623+
patch.object(_engine, "add_multicast_member", return_value=True),
624+
patch.object(_engine, "new_respond_socket", return_value=None),
625+
):
626+
await engine.async_update_interfaces(["unused"], IPVersion.All, False)
627+
628+
new_listen = engine._listen_transport
629+
assert new_listen is not None
630+
assert new_listen is not old_listen
631+
assert new_listen.sock.family == socket.AF_INET6 # rebuilt to a dual-stack socket
632+
# The old listen socket was closed and removed; no duplicate remains.
633+
assert old_underlying.is_closing()
634+
assert old_listen not in engine.readers
635+
assert sum(1 for r in engine.readers if r is new_listen) == 1
636+
637+
547638
@pytest.mark.asyncio
548639
async def test_close_sender_closes_transport_when_drop_raises(aiozc_loopback: AsyncZeroconf) -> None:
549640
"""A non-benign group-leave error still releases the transport."""

0 commit comments

Comments
 (0)