From b262b0eade328c08214c3171a22e8fa80c9c4aed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 18:09:32 -0500 Subject: [PATCH 1/2] fix: prune dead transports from the engine on connection loss --- src/zeroconf/_engine.py | 15 +++++++++++++++ src/zeroconf/_listener.py | 3 ++- tests/test_engine.py | 24 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index 0e1c01a1..a6990ae6 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -126,6 +126,21 @@ async def _async_create_endpoints(self) -> None: if s in self._respond_sockets: self._respond_sockets.remove(s) + def _async_remove_listener(self, listener: AsyncListener) -> None: + """Drop a listener and its wrapped transports from the engine lists. + + Called from ``AsyncListener.connection_lost`` so a transport that + dies (interface down, IP changed) stops being used as a sender + instead of raising ``EHOSTUNREACH`` on every send forever. + """ + wrapped = listener.transport + transport = wrapped.transport if wrapped is not None else None + if listener in self.protocols: + self.protocols.remove(listener) + if transport is not None: + self.readers = [w for w in self.readers if w.transport is not transport] + self.senders = [w for w in self.senders if w.transport is not transport] + def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" now = current_time_millis() diff --git a/src/zeroconf/_listener.py b/src/zeroconf/_listener.py index 7be2a828..ac7347da 100644 --- a/src/zeroconf/_listener.py +++ b/src/zeroconf/_listener.py @@ -356,4 +356,5 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None: self.sock_description = f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})" def connection_lost(self, exc: Exception | None) -> None: - """Handle connection lost.""" + """Prune this transport from the engine so a dead socket is not reused.""" + self.zc.engine._async_remove_listener(self) diff --git a/tests/test_engine.py b/tests/test_engine.py index 66cfa356..e1d76c94 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -84,6 +84,30 @@ async def test_setup_releases_socket_ownership(aiozc_loopback: AsyncZeroconf) -> assert engine.senders +@pytest.mark.asyncio +async def test_connection_lost_prunes_transport(aiozc_loopback: AsyncZeroconf) -> None: + """A lost transport is removed from the engine reader/sender/protocol lists.""" + await aiozc_loopback.zeroconf.async_wait_for_start() + engine = aiozc_loopback.zeroconf.engine + assert engine.senders + reader_count = len(engine.readers) + sender_count = len(engine.senders) + protocol_count = len(engine.protocols) + + dead_transport = engine.senders[0].transport + protocol = next( + p for p in engine.protocols if p.transport is not None and p.transport.transport is dead_transport + ) + protocol.connection_lost(None) + + assert len(engine.senders) == sender_count - 1 + assert len(engine.readers) == reader_count - 1 + assert len(engine.protocols) == protocol_count - 1 + assert all(w.transport is not dead_transport for w in engine.senders) + assert all(w.transport is not dead_transport for w in engine.readers) + assert protocol not in engine.protocols + + @pytest.mark.asyncio async def test_async_close_propagates_outer_cancellation(aiozc_loopback: AsyncZeroconf) -> None: """Outer-task cancellation while awaiting setup propagates to the caller.""" From 530b7ff21013ffef8627c629bdba1d7994dd6a55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 21 Jun 2026 18:14:44 -0500 Subject: [PATCH 2/2] test: cover connection_lost guard branches --- src/zeroconf/_engine.py | 7 +------ tests/test_engine.py | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/zeroconf/_engine.py b/src/zeroconf/_engine.py index a6990ae6..291a0d3c 100644 --- a/src/zeroconf/_engine.py +++ b/src/zeroconf/_engine.py @@ -127,12 +127,7 @@ async def _async_create_endpoints(self) -> None: self._respond_sockets.remove(s) def _async_remove_listener(self, listener: AsyncListener) -> None: - """Drop a listener and its wrapped transports from the engine lists. - - Called from ``AsyncListener.connection_lost`` so a transport that - dies (interface down, IP changed) stops being used as a sender - instead of raising ``EHOSTUNREACH`` on every send forever. - """ + """Drop a listener and its wrapped transports from the engine lists.""" wrapped = listener.transport transport = wrapped.transport if wrapped is not None else None if listener in self.protocols: diff --git a/tests/test_engine.py b/tests/test_engine.py index e1d76c94..43fcf510 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -10,7 +10,7 @@ import pytest import zeroconf as r -from zeroconf import _engine, const +from zeroconf import _engine, _listener, const from zeroconf.asyncio import AsyncZeroconf log = logging.getLogger("zeroconf") @@ -108,6 +108,24 @@ async def test_connection_lost_prunes_transport(aiozc_loopback: AsyncZeroconf) - assert protocol not in engine.protocols +@pytest.mark.asyncio +async def test_connection_lost_without_transport_is_noop(aiozc_loopback: AsyncZeroconf) -> None: + """connection_lost on a listener that never bound a transport leaves the lists intact.""" + await aiozc_loopback.zeroconf.async_wait_for_start() + engine = aiozc_loopback.zeroconf.engine + reader_count = len(engine.readers) + sender_count = len(engine.senders) + protocol_count = len(engine.protocols) + + orphan = _listener.AsyncListener(aiozc_loopback.zeroconf) + assert orphan.transport is None + orphan.connection_lost(None) + + assert len(engine.readers) == reader_count + assert len(engine.senders) == sender_count + assert len(engine.protocols) == protocol_count + + @pytest.mark.asyncio async def test_async_close_propagates_outer_cancellation(aiozc_loopback: AsyncZeroconf) -> None: """Outer-task cancellation while awaiting setup propagates to the caller."""