@@ -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
548639async 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