Skip to content

Commit b262b0e

Browse files
committed
fix: prune dead transports from the engine on connection loss
1 parent 5333fc5 commit b262b0e

3 files changed

Lines changed: 41 additions & 1 deletion

File tree

src/zeroconf/_engine.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,21 @@ async def _async_create_endpoints(self) -> None:
126126
if s in self._respond_sockets:
127127
self._respond_sockets.remove(s)
128128

129+
def _async_remove_listener(self, listener: AsyncListener) -> None:
130+
"""Drop a listener and its wrapped transports from the engine lists.
131+
132+
Called from ``AsyncListener.connection_lost`` so a transport that
133+
dies (interface down, IP changed) stops being used as a sender
134+
instead of raising ``EHOSTUNREACH`` on every send forever.
135+
"""
136+
wrapped = listener.transport
137+
transport = wrapped.transport if wrapped is not None else None
138+
if listener in self.protocols:
139+
self.protocols.remove(listener)
140+
if transport is not None:
141+
self.readers = [w for w in self.readers if w.transport is not transport]
142+
self.senders = [w for w in self.senders if w.transport is not transport]
143+
129144
def _async_cache_cleanup(self) -> None:
130145
"""Periodic cache cleanup."""
131146
now = current_time_millis()

src/zeroconf/_listener.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,4 +356,5 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None:
356356
self.sock_description = f"{wrapped_transport.fileno} ({wrapped_transport.sock_name})"
357357

358358
def connection_lost(self, exc: Exception | None) -> None:
359-
"""Handle connection lost."""
359+
"""Prune this transport from the engine so a dead socket is not reused."""
360+
self.zc.engine._async_remove_listener(self)

tests/test_engine.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,30 @@ async def test_setup_releases_socket_ownership(aiozc_loopback: AsyncZeroconf) ->
8484
assert engine.senders
8585

8686

87+
@pytest.mark.asyncio
88+
async def test_connection_lost_prunes_transport(aiozc_loopback: AsyncZeroconf) -> None:
89+
"""A lost transport is removed from the engine reader/sender/protocol lists."""
90+
await aiozc_loopback.zeroconf.async_wait_for_start()
91+
engine = aiozc_loopback.zeroconf.engine
92+
assert engine.senders
93+
reader_count = len(engine.readers)
94+
sender_count = len(engine.senders)
95+
protocol_count = len(engine.protocols)
96+
97+
dead_transport = engine.senders[0].transport
98+
protocol = next(
99+
p for p in engine.protocols if p.transport is not None and p.transport.transport is dead_transport
100+
)
101+
protocol.connection_lost(None)
102+
103+
assert len(engine.senders) == sender_count - 1
104+
assert len(engine.readers) == reader_count - 1
105+
assert len(engine.protocols) == protocol_count - 1
106+
assert all(w.transport is not dead_transport for w in engine.senders)
107+
assert all(w.transport is not dead_transport for w in engine.readers)
108+
assert protocol not in engine.protocols
109+
110+
87111
@pytest.mark.asyncio
88112
async def test_async_close_propagates_outer_cancellation(aiozc_loopback: AsyncZeroconf) -> None:
89113
"""Outer-task cancellation while awaiting setup propagates to the caller."""

0 commit comments

Comments
 (0)