Skip to content

Commit 0e25328

Browse files
committed
fix: drain _seen_logs in a while loop so drift can't inflate the cap
Under free-threaded (3.14t) contention or sustained multi-instance sync use, two callers can both pass the ``len(seen) < _MAX_SEEN_LOGS`` check and both insert before either evicts — leaving the dict transiently above the cap. The previous ``if``-based eviction removed one entry on the next call, but the new insert added one back, so the overshoot never drained and the dict silently grew with thread count. Switch the eviction to ``while len(seen) >= _MAX_SEEN_LOGS:`` so a single non-racing caller drains the accumulated drift back to the cap. Bail on ``RuntimeError`` and ``StopIteration`` so a concurrent mutation can't make the loop spin. Add ``test_mark_seen_drains_drift_above_cap`` to pin the drain behaviour — a regression to single-eviction would leave the dict at ``cap + drift - 1`` and fail the assertion.
1 parent ebe1ab2 commit 0e25328

2 files changed

Lines changed: 32 additions & 11 deletions

File tree

src/zeroconf/_logger.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def _mark_seen(seen: dict[str, None], key: str) -> bool:
4848
4949
Bounds the dict so callers passing attacker-influenced keys (peer
5050
addresses, packet offsets) cannot grow it without bound. Evicts
51-
the oldest entry per overflow (dict preserves insertion order on
51+
the oldest entries on overflow (dict preserves insertion order on
5252
Python 3.7+), so ``_MAX_SEEN_LOGS`` is a recency window.
5353
5454
The dict is shared across all ``Zeroconf`` instances in the
@@ -57,20 +57,20 @@ def _mark_seen(seen: dict[str, None], key: str) -> bool:
5757
(``in``, ``__setitem__``, ``pop``, ``len``) are atomic in CPython
5858
3.13+ FT and don't crash, but the compound ``iter`` → ``next``
5959
used to find the FIFO victim can raise ``RuntimeError`` if
60-
another thread mutates the dict between the two ops. Catch and
61-
skip — the other thread is already shrinking the set, so missing
62-
one eviction here just lets the cap drift up by one entry per
63-
racing thread until contention clears.
60+
another thread mutates the dict between the two ops. The
61+
eviction loop drains until ``len(seen) < _MAX_SEEN_LOGS`` so that
62+
any drift accumulated by prior concurrent inserts is corrected by
63+
the next caller, and bails on ``RuntimeError`` (another thread is
64+
already shrinking the set) so we don't spin.
6465
"""
6566
if key in seen:
6667
return False
67-
if len(seen) >= _MAX_SEEN_LOGS:
68+
while len(seen) >= _MAX_SEEN_LOGS:
6869
try:
69-
oldest = next(iter(seen), None)
70-
except RuntimeError:
71-
oldest = None
72-
if oldest is not None:
73-
seen.pop(oldest, None)
70+
oldest = next(iter(seen))
71+
except (RuntimeError, StopIteration):
72+
break
73+
seen.pop(oldest, None)
7474
seen[key] = None
7575
return True
7676

tests/test_logger.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,27 @@ def __iter__(self): # type: ignore[override]
105105
assert "new-key" in seen
106106

107107

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+
108129
def test_seen_logs_is_bounded() -> None:
109130
"""``_seen_logs`` stays at the cap and evicts oldest-first (FIFO)."""
110131
_logger._seen_logs.clear()

0 commit comments

Comments
 (0)