55import logging
66from unittest .mock import call , patch
77
8- from zeroconf ._logger import QuietLogger , set_logger_level_if_unset
8+ from zeroconf import _logger
9+ from zeroconf ._logger import _MAX_SEEN_LOGS , QuietLogger , _mark_seen , set_logger_level_if_unset
910
1011
1112def test_loading_logger ():
@@ -25,7 +26,7 @@ def test_loading_logger():
2526
2627def test_log_warning_once ():
2728 """Test we only log with warning level once."""
28- QuietLogger ._seen_logs = {}
29+ _logger ._seen_logs . clear ()
2930 quiet_logger = QuietLogger ()
3031 with (
3132 patch ("zeroconf._logger.log.warning" ) as mock_log_warning ,
@@ -48,7 +49,7 @@ def test_log_warning_once():
4849
4950def test_log_exception_warning ():
5051 """Test we only log with warning level once."""
51- QuietLogger ._seen_logs = {}
52+ _logger ._seen_logs . clear ()
5253 quiet_logger = QuietLogger ()
5354 with (
5455 patch ("zeroconf._logger.log.warning" ) as mock_log_warning ,
@@ -71,7 +72,7 @@ def test_log_exception_warning():
7172
7273def test_llog_exception_debug ():
7374 """Test we only log with a trace once."""
74- QuietLogger ._seen_logs = {}
75+ _logger ._seen_logs . clear ()
7576 quiet_logger = QuietLogger ()
7677 with patch ("zeroconf._logger.log.debug" ) as mock_log_debug :
7778 quiet_logger .log_exception_debug ("the exception" )
@@ -84,9 +85,85 @@ def test_llog_exception_debug():
8485 assert mock_log_debug .mock_calls == [call ("the exception" , exc_info = False )]
8586
8687
88+ def test_mark_seen_absorbs_runtime_error_during_eviction () -> None :
89+ """Concurrent mutation can make ``iter(seen)`` raise ``RuntimeError``.
90+
91+ Free-threaded (3.14t) and multi-instance sync callers share
92+ ``_seen_logs``; if another thread mutates it between ``iter()``
93+ and ``next()`` the iterator raises ``RuntimeError``.
94+ ``_mark_seen`` must absorb that and still insert the new key.
95+ """
96+
97+ class RacyDict (dict [str , None ]):
98+ def __iter__ (self ): # type: ignore[override]
99+ raise RuntimeError ("dictionary changed size during iteration" )
100+
101+ seen : dict [str , None ] = RacyDict ()
102+ for i in range (_MAX_SEEN_LOGS ):
103+ seen [f"k-{ i } " ] = None
104+ assert _mark_seen (seen , "new-key" ) is True
105+ assert "new-key" in seen
106+
107+
108+ def test_mark_seen_drains_drift_above_cap () -> None :
109+ """``_mark_seen`` drains a drifted-over-cap dict back to the cap.
110+
111+ Concurrent inserts on the free-threaded build can leave the dict
112+ transiently above ``_MAX_SEEN_LOGS`` (e.g. two threads both passed
113+ the ``len < cap`` check and both inserted). The next non-racing
114+ call must drain the accumulated overshoot, not just evict one
115+ entry — otherwise the cap silently inflates with thread count.
116+ """
117+ seen : dict [str , None ] = {}
118+ drift = 10
119+ for i in range (_MAX_SEEN_LOGS + drift ):
120+ seen [f"k-{ i } " ] = None
121+ assert len (seen ) == _MAX_SEEN_LOGS + drift
122+ assert _mark_seen (seen , "new-key" ) is True
123+ assert len (seen ) == _MAX_SEEN_LOGS
124+ assert "new-key" in seen
125+ for i in range (drift + 1 ):
126+ assert f"k-{ i } " not in seen
127+
128+
129+ def test_mark_seen_drains_drift_on_hit_path () -> None :
130+ """``_mark_seen`` drains drift even when ``key`` is already cached.
131+
132+ A hit-heavy workload after a contention burst (e.g. the same
133+ exception text deduplicated repeatedly) must still correct the
134+ overshoot — otherwise the dict can sit permanently above the cap
135+ until a miss happens to come along.
136+ """
137+ seen : dict [str , None ] = {}
138+ drift = 10
139+ for i in range (_MAX_SEEN_LOGS + drift ):
140+ seen [f"k-{ i } " ] = None
141+ # Hit on a non-oldest key — survives the drift drain.
142+ hit_key = f"k-{ _MAX_SEEN_LOGS } "
143+ assert _mark_seen (seen , hit_key ) is False
144+ assert len (seen ) == _MAX_SEEN_LOGS
145+ assert hit_key in seen
146+ for i in range (drift ):
147+ assert f"k-{ i } " not in seen
148+
149+
150+ def test_seen_logs_is_bounded () -> None :
151+ """``_seen_logs`` stays at the cap and evicts oldest-first (FIFO)."""
152+ _logger ._seen_logs .clear ()
153+ overflow = 5
154+ with patch ("zeroconf._logger.log.warning" ), patch ("zeroconf._logger.log.debug" ):
155+ for i in range (_MAX_SEEN_LOGS + overflow ):
156+ QuietLogger .log_warning_once (f"warning-{ i } " )
157+ assert len (_logger ._seen_logs ) == _MAX_SEEN_LOGS
158+ for i in range (overflow ):
159+ assert f"warning-{ i } " not in _logger ._seen_logs
160+ for i in range (_MAX_SEEN_LOGS , _MAX_SEEN_LOGS + overflow ):
161+ assert f"warning-{ i } " in _logger ._seen_logs
162+
163+
87164def test_log_exception_once ():
88165 """Test we only log with warning level once."""
89- QuietLogger ._seen_logs = {}
166+ _logger ._seen_logs . clear ()
90167 quiet_logger = QuietLogger ()
91168 exc = Exception ()
92169 with (
0 commit comments