zeroconf 0.149.6__tar.gz → 0.149.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of zeroconf might be problematic. Click here for more details.

Files changed (100) hide show
  1. {zeroconf-0.149.6 → zeroconf-0.149.7}/CHANGELOG.md +15 -0
  2. {zeroconf-0.149.6 → zeroconf-0.149.7}/PKG-INFO +1 -1
  3. {zeroconf-0.149.6 → zeroconf-0.149.7}/pyproject.toml +1 -1
  4. {zeroconf-0.149.6 → zeroconf-0.149.7}/setup.py +1 -1
  5. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/__init__.py +1 -1
  6. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_cache.pxd +10 -1
  7. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_cache.py +51 -28
  8. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/const.py +6 -0
  9. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/__init__.py +5 -3
  10. zeroconf-0.149.7/tests/benchmarks/test_cache_bound.py +68 -0
  11. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/conftest.py +9 -5
  12. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/services/test_types.py +4 -4
  13. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_cache.py +239 -2
  14. {zeroconf-0.149.6 → zeroconf-0.149.7}/COPYING +0 -0
  15. {zeroconf-0.149.6 → zeroconf-0.149.7}/README.rst +0 -0
  16. {zeroconf-0.149.6 → zeroconf-0.149.7}/build_ext.py +0 -0
  17. {zeroconf-0.149.6 → zeroconf-0.149.7}/docs/Makefile +0 -0
  18. {zeroconf-0.149.6 → zeroconf-0.149.7}/docs/_ext/zeroconfautodocfix.py +0 -0
  19. {zeroconf-0.149.6 → zeroconf-0.149.7}/docs/api.rst +0 -0
  20. {zeroconf-0.149.6 → zeroconf-0.149.7}/docs/conf.py +0 -0
  21. {zeroconf-0.149.6 → zeroconf-0.149.7}/docs/index.rst +0 -0
  22. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_core.py +0 -0
  23. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_dns.pxd +0 -0
  24. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_dns.py +0 -0
  25. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_engine.py +0 -0
  26. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_exceptions.py +0 -0
  27. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/__init__.py +0 -0
  28. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/answers.pxd +0 -0
  29. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/answers.py +0 -0
  30. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +0 -0
  31. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/multicast_outgoing_queue.py +0 -0
  32. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/query_handler.pxd +0 -0
  33. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/query_handler.py +0 -0
  34. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/record_manager.pxd +0 -0
  35. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/record_manager.py +0 -0
  36. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_history.pxd +0 -0
  37. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_history.py +0 -0
  38. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_listener.pxd +0 -0
  39. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_listener.py +0 -0
  40. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_logger.py +0 -0
  41. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_protocol/__init__.py +0 -0
  42. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_protocol/incoming.pxd +0 -0
  43. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_protocol/incoming.py +0 -0
  44. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_protocol/outgoing.pxd +0 -0
  45. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_protocol/outgoing.py +0 -0
  46. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_record_update.pxd +0 -0
  47. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_record_update.py +0 -0
  48. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/__init__.pxd +0 -0
  49. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/__init__.py +0 -0
  50. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/browser.pxd +0 -0
  51. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/browser.py +0 -0
  52. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/info.pxd +0 -0
  53. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/info.py +0 -0
  54. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/registry.pxd +0 -0
  55. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/registry.py +0 -0
  56. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/types.py +0 -0
  57. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_transport.py +0 -0
  58. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_updates.pxd +0 -0
  59. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_updates.py +0 -0
  60. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/__init__.py +0 -0
  61. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/asyncio.py +0 -0
  62. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/ipaddress.pxd +0 -0
  63. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/ipaddress.py +0 -0
  64. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/name.py +0 -0
  65. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/net.py +0 -0
  66. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/time.pxd +0 -0
  67. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/time.py +0 -0
  68. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/asyncio.py +0 -0
  69. {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/py.typed +0 -0
  70. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/__init__.py +0 -0
  71. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/helpers.py +0 -0
  72. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_cache.py +0 -0
  73. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_incoming.py +0 -0
  74. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_mark_seen.py +0 -0
  75. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_outgoing.py +0 -0
  76. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_send.py +0 -0
  77. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_txt_properties.py +0 -0
  78. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/services/__init__.py +0 -0
  79. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/services/test_browser.py +0 -0
  80. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/services/test_info.py +0 -0
  81. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/services/test_registry.py +0 -0
  82. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_asyncio.py +0 -0
  83. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_circular_imports.py +0 -0
  84. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_core.py +0 -0
  85. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_dns.py +0 -0
  86. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_engine.py +0 -0
  87. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_exceptions.py +0 -0
  88. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_handlers.py +0 -0
  89. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_history.py +0 -0
  90. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_init.py +0 -0
  91. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_listener.py +0 -0
  92. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_logger.py +0 -0
  93. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_protocol.py +0 -0
  94. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_services.py +0 -0
  95. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_updates.py +0 -0
  96. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/utils/__init__.py +0 -0
  97. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/utils/test_asyncio.py +0 -0
  98. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/utils/test_ipaddress.py +0 -0
  99. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/utils/test_name.py +0 -0
  100. {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/utils/test_net.py +0 -0
@@ -2,6 +2,21 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.149.7 (2026-05-18)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Bound DNSCache record count to prevent unbounded LAN-driven growth
10
+ ([#1718](https://github.com/python-zeroconf/python-zeroconf/pull/1718),
11
+ [`0ad3f37`](https://github.com/python-zeroconf/python-zeroconf/commit/0ad3f37b5b852b8f614d322283d148efb2cef6e4))
12
+
13
+ ### Testing
14
+
15
+ - Shave ServiceBrowser first-query delay on loopback
16
+ ([#1720](https://github.com/python-zeroconf/python-zeroconf/pull/1720),
17
+ [`0ff3c6b`](https://github.com/python-zeroconf/python-zeroconf/commit/0ff3c6b9dd40e01263ce88803139c3ba68349682))
18
+
19
+
5
20
  ## v0.149.6 (2026-05-18)
6
21
 
7
22
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zeroconf
3
- Version: 0.149.6
3
+ Version: 0.149.7
4
4
  Summary: A pure python implementation of multicast DNS service discovery
5
5
  License-Expression: LGPL-2.1-or-later
6
6
  License-File: COPYING
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "zeroconf"
7
- version = "0.149.6"
7
+ version = "0.149.7"
8
8
  license = "LGPL-2.1-or-later"
9
9
  description = "A pure python implementation of multicast DNS service discovery"
10
10
  readme = "README.rst"
@@ -19,7 +19,7 @@ install_requires = \
19
19
 
20
20
  setup_kwargs = {
21
21
  'name': 'zeroconf',
22
- 'version': '0.149.6',
22
+ 'version': '0.149.7',
23
23
  'description': 'A pure python implementation of multicast DNS service discovery',
24
24
  'long_description': 'python-zeroconf\n===============\n\n.. image:: https://github.com/python-zeroconf/python-zeroconf/workflows/CI/badge.svg\n :target: https://github.com/python-zeroconf/python-zeroconf?query=workflow%3ACI+branch%3Amaster\n\n.. image:: https://img.shields.io/pypi/v/zeroconf.svg\n :target: https://pypi.python.org/pypi/zeroconf\n\n.. image:: https://codecov.io/gh/python-zeroconf/python-zeroconf/branch/master/graph/badge.svg\n :target: https://codecov.io/gh/python-zeroconf/python-zeroconf\n\n.. image:: https://img.shields.io/endpoint?url=https://codspeed.io/badge.json\n :target: https://codspeed.io/python-zeroconf/python-zeroconf\n :alt: Codspeed.io status for python-zeroconf\n\n.. image:: https://readthedocs.org/projects/python-zeroconf/badge/?version=latest\n :target: https://python-zeroconf.readthedocs.io/en/latest/?badge=latest\n :alt: Documentation Status\n\n`Documentation <https://python-zeroconf.readthedocs.io/en/latest/>`_.\n\nThis is fork of pyzeroconf, Multicast DNS Service Discovery for Python,\noriginally by Paul Scott-Murphy (https://github.com/paulsm/pyzeroconf),\nmodified by William McBrine (https://github.com/wmcbrine/pyzeroconf).\n\nThe original William McBrine\'s fork note::\n\n This fork is used in all of my TiVo-related projects: HME for Python\n (and therefore HME/VLC), Network Remote, Remote Proxy, and pyTivo.\n Before this, I was tracking the changes for zeroconf.py in three\n separate repos. I figured I should have an authoritative source.\n\n Although I make changes based on my experience with TiVos, I expect that\n they\'re generally applicable. This version also includes patches found\n on the now-defunct (?) Launchpad repo of pyzeroconf, and elsewhere\n around the net -- not always well-documented, sorry.\n\nCompatible with:\n\n* Bonjour\n* Avahi\n\nCompared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf:\n\n* isn\'t tied to Bonjour or Avahi\n* doesn\'t use D-Bus\n* doesn\'t force you to use particular event loop or Twisted (asyncio is used under the hood but not required)\n* is pip-installable\n* has PyPI distribution\n* has an optional cython extension for performance (pure python is supported as well)\n\nPython compatibility\n--------------------\n\n* CPython 3.10+\n* PyPy 3.10+\n\nVersioning\n----------\n\nThis project uses semantic versioning.\n\nStatus\n------\n\nThis project is actively maintained.\n\nTraffic Reduction\n-----------------\n\nBefore version 0.32, most traffic reduction techniques described in https://datatracker.ietf.org/doc/html/rfc6762#section-7\nwhere not implemented which could lead to excessive network traffic. It is highly recommended that version 0.32 or later\nis used if this is a concern.\n\nIPv6 support\n------------\n\nIPv6 support is relatively new and currently limited, specifically:\n\n* `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` on non-POSIX\n systems.\n* Dual-stack IPv6 sockets are used, which may not be supported everywhere (some\n BSD variants do not have them).\n* Listening on localhost (`::1`) does not work. Help with understanding why is\n appreciated.\n\nHow to get python-zeroconf?\n===========================\n\n* PyPI page https://pypi.org/project/zeroconf/\n* GitHub project https://github.com/python-zeroconf/python-zeroconf\n\nThe easiest way to install python-zeroconf is using pip::\n\n pip install zeroconf\n\n\n\nHow do I use it?\n================\n\nHere\'s an example of browsing for a service:\n\n.. code-block:: python\n\n from zeroconf import ServiceBrowser, ServiceListener, Zeroconf\n\n\n class MyListener(ServiceListener):\n\n def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"Service {name} updated")\n\n def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"Service {name} removed")\n\n def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n info = zc.get_service_info(type_, name)\n print(f"Service {name} added, service info: {info}")\n\n\n zeroconf = Zeroconf()\n listener = MyListener()\n browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener)\n try:\n input("Press enter to exit...\\n\\n")\n finally:\n zeroconf.close()\n\n.. note::\n\n Discovery and service registration use *all* available network interfaces by default.\n If you want to customize that you need to specify ``interfaces`` argument when\n constructing ``Zeroconf`` object (see the code for details).\n\nIf you don\'t know the name of the service you need to browse for, try:\n\n.. code-block:: python\n\n from zeroconf import ZeroconfServiceTypes\n print(\'\\n\'.join(ZeroconfServiceTypes.find()))\n\nSee examples directory for more.\n\nChangelog\n=========\n\n`Changelog <CHANGELOG.md>`_\n\nLicense\n=======\n\nLGPL, see COPYING file for details.\n',
25
25
  'author': 'Paul Scott-Murphy',
@@ -88,7 +88,7 @@ from ._utils.time import ( # noqa # import needed for backwards compat
88
88
 
89
89
  __author__ = "Paul Scott-Murphy, William McBrine"
90
90
  __maintainer__ = "Jakub Stasiak <jakub@stasiak.at>"
91
- __version__ = "0.149.6"
91
+ __version__ = "0.149.7"
92
92
  __license__ = "LGPL"
93
93
 
94
94
 
@@ -19,6 +19,7 @@ cdef object _UNIQUE_RECORD_TYPES
19
19
  cdef unsigned int _TYPE_PTR
20
20
  cdef cython.uint _ONE_SECOND
21
21
  cdef unsigned int _MIN_SCHEDULED_RECORD_EXPIRATION
22
+ cdef unsigned int _MAX_CACHE_RECORDS
22
23
 
23
24
 
24
25
  @cython.locals(record_cache=dict)
@@ -31,6 +32,7 @@ cdef class DNSCache:
31
32
  cdef public cython.dict service_cache
32
33
  cdef public list _expire_heap
33
34
  cdef public dict _expirations
35
+ cdef public unsigned int _total_records
34
36
 
35
37
  cpdef bint async_add_records(self, object entries)
36
38
 
@@ -60,10 +62,17 @@ cdef class DNSCache:
60
62
  service_store=cython.dict,
61
63
  service_record=DNSService,
62
64
  when=object,
63
- new=bint
65
+ new=bint,
66
+ is_new=bint
64
67
  )
65
68
  cdef bint _async_add(self, DNSRecord record)
66
69
 
70
+ @cython.locals(record=DNSRecord, when_record=tuple)
71
+ cdef void _async_evict_oldest(self)
72
+
73
+ @cython.locals(expire_heap_len="unsigned int")
74
+ cdef void _maybe_rebuild_heap(self)
75
+
67
76
  @cython.locals(service_record=DNSService)
68
77
  cdef void _async_remove(self, DNSRecord record)
69
78
 
@@ -37,7 +37,7 @@ from ._dns import (
37
37
  DNSText,
38
38
  )
39
39
  from ._utils.time import current_time_millis
40
- from .const import _ONE_SECOND, _TYPE_PTR
40
+ from .const import _MAX_CACHE_RECORDS, _ONE_SECOND, _TYPE_PTR
41
41
 
42
42
  _UNIQUE_RECORD_TYPES = (DNSAddress, DNSHinfo, DNSPointer, DNSText, DNSService)
43
43
  _UniqueRecordsType = DNSAddress | DNSHinfo | DNSPointer | DNSText | DNSService
@@ -72,6 +72,7 @@ class DNSCache:
72
72
  self._expire_heap: list[tuple[float, DNSRecord]] = []
73
73
  self._expirations: dict[DNSRecord, float] = {}
74
74
  self.service_cache: _DNSRecordCacheType = {}
75
+ self._total_records: int = 0
75
76
 
76
77
  # Functions prefixed with async_ are NOT threadsafe and must
77
78
  # be run in the event loop.
@@ -89,15 +90,34 @@ class DNSCache:
89
90
  # replaces any existing records that are __eq__ to each other which
90
91
  # removes the risk that accessing the cache from the wrong
91
92
  # direction would return the old incorrect entry.
92
- if (store := self.cache.get(record.key)) is None:
93
+ store = self.cache.get(record.key)
94
+ is_new = store is None or record not in store
95
+ # Bound total cache size; evict closest-to-expiration entry to
96
+ # make room before inserting a new record. Prevents a LAN-local
97
+ # flood of unique-name records from growing the cache without
98
+ # bound (RFC 6762 §10 advisory caching, defense-in-depth).
99
+ if is_new and self._total_records >= _MAX_CACHE_RECORDS:
100
+ self._async_evict_oldest()
101
+ # The victim may have been the last record under
102
+ # ``record.key``, in which case ``_remove_key`` deleted
103
+ # the bucket. Re-fetch before creating below.
104
+ store = self.cache.get(record.key)
105
+ if store is None:
93
106
  store = self.cache[record.key] = {}
94
- new = record not in store and not isinstance(record, DNSNsec)
107
+ new = is_new and not isinstance(record, DNSNsec)
108
+ if is_new:
109
+ self._total_records += 1
95
110
  store[record] = record
96
111
  when = record.created + (record.ttl * 1000)
97
112
  if self._expirations.get(record) != when:
98
- # Avoid adding duplicates to the heap
99
113
  heappush(self._expire_heap, (when, record))
100
114
  self._expirations[record] = when
115
+ # Re-adds of an existing record with a new TTL push a fresh
116
+ # entry but leave the prior tuple behind as stale, so a peer
117
+ # that just replays cached records can grow ``_expire_heap``
118
+ # without ever tripping the cap. Rebuild when stale entries
119
+ # dominate.
120
+ self._maybe_rebuild_heap()
101
121
 
102
122
  if isinstance(record, DNSService):
103
123
  service_record = record
@@ -106,6 +126,28 @@ class DNSCache:
106
126
  service_store[service_record] = service_record
107
127
  return new
108
128
 
129
+ def _async_evict_oldest(self) -> None:
130
+ """Drop the closest-to-expiration record to make room for a new one."""
131
+ while self._expire_heap:
132
+ when_record = heappop(self._expire_heap)
133
+ record = when_record[1]
134
+ if self._expirations.get(record) != when_record[0]:
135
+ continue
136
+ self._async_remove(record)
137
+ return
138
+
139
+ def _maybe_rebuild_heap(self) -> None:
140
+ """Rebuild ``_expire_heap`` when stale entries dominate live ones."""
141
+ expire_heap_len = len(self._expire_heap)
142
+ if (
143
+ expire_heap_len > _MIN_SCHEDULED_RECORD_EXPIRATION
144
+ and expire_heap_len > len(self._expirations) * 2
145
+ ):
146
+ self._expire_heap = [
147
+ entry for entry in self._expire_heap if self._expirations.get(entry[1]) == entry[0]
148
+ ]
149
+ heapify(self._expire_heap)
150
+
109
151
  def async_add_records(self, entries: Iterable[DNSRecord]) -> bool:
110
152
  """Add multiple records.
111
153
 
@@ -129,6 +171,7 @@ class DNSCache:
129
171
  _remove_key(self.service_cache, service_record.server_key, service_record)
130
172
  _remove_key(self.cache, record.key, record)
131
173
  self._expirations.pop(record, None)
174
+ self._total_records -= 1
132
175
 
133
176
  def async_remove_records(self, entries: Iterable[DNSRecord]) -> None:
134
177
  """Remove multiple records.
@@ -145,43 +188,23 @@ class DNSCache:
145
188
 
146
189
  :param now: The current time in milliseconds.
147
190
  """
148
- if not (expire_heap_len := len(self._expire_heap)):
191
+ if not self._expire_heap:
149
192
  return []
150
193
 
151
194
  expired: list[DNSRecord] = []
152
- # Find any expired records and add them to the to-delete list
153
195
  while self._expire_heap:
154
196
  when_record = self._expire_heap[0]
155
197
  when = when_record[0]
156
198
  if when > now:
157
199
  break
158
200
  heappop(self._expire_heap)
159
- # Check if the record hasn't been re-added to the heap
160
- # with a different expiration time as it will be removed
161
- # later when it reaches the top of the heap and its
162
- # expiration time is met.
201
+ # Skip entries left behind by a TTL re-add; the live tuple is
202
+ # later in the heap and will be removed when it reaches the top.
163
203
  record = when_record[1]
164
204
  if self._expirations.get(record) == when:
165
205
  expired.append(record)
166
206
 
167
- # If the expiration heap grows larger than the number expirations
168
- # times two, we clean it up to avoid keeping expired entries in
169
- # the heap and consuming memory. We guard this with a minimum
170
- # threshold to avoid cleaning up the heap too often when there are
171
- # only a few scheduled expirations.
172
- if (
173
- expire_heap_len > _MIN_SCHEDULED_RECORD_EXPIRATION
174
- and expire_heap_len > len(self._expirations) * 2
175
- ):
176
- # Remove any expired entries from the expiration heap
177
- # that do not match the expiration time in the expirations
178
- # as it means the record has been re-added to the heap
179
- # with a different expiration time.
180
- self._expire_heap = [
181
- entry for entry in self._expire_heap if self._expirations.get(entry[1]) == entry[0]
182
- ]
183
- heapify(self._expire_heap)
184
-
207
+ self._maybe_rebuild_heap()
185
208
  self.async_remove_records(expired)
186
209
  return expired
187
210
 
@@ -59,6 +59,12 @@ _DNS_OTHER_TTL = 4500 # 75 minutes for non-host records (PTR, TXT etc) as-per R
59
59
  # level of rate limit and safe guards so we use 1/4 of the recommended value
60
60
  _DNS_PTR_MIN_TTL = 1125
61
61
 
62
+ # Upper bound on the number of records the DNSCache will hold before it
63
+ # starts evicting the closest-to-expiration entry to make room for new
64
+ # arrivals. Bounds the memory a malicious LAN peer can force the cache
65
+ # to retain by multicasting many unique-name records.
66
+ _MAX_CACHE_RECORDS = 10000
67
+
62
68
  _DNS_PACKET_HEADER_LEN = 12
63
69
 
64
70
  _MAX_MSG_TYPICAL = 1460 # unused
@@ -45,9 +45,11 @@ QUICK_REQUEST_TIMEOUT_MS = 50
45
45
  # Timeout for ZeroconfServiceTypes.find() / AsyncZeroconfServiceTypes.async_find()
46
46
  # in loopback integration tests. `find()` is just `time.sleep(timeout)` —
47
47
  # it doesn't short-circuit on the first matching response — so the
48
- # timeout becomes a lower bound on the test runtime. On loopback the
49
- # registrar's response lands within a few ms; 200ms is ~50x headroom.
50
- LOOPBACK_FIND_TIMEOUT = 0.2
48
+ # timeout becomes a lower bound on the test runtime. Callers MUST use
49
+ # the `quick_timing` fixture, which shrinks the browser's first-query
50
+ # delay from RFC 6762 §5.2's 20-120ms window to 1-5ms; with that shave
51
+ # the registrar's response lands inside ~10ms and 75ms is ~7x headroom.
52
+ LOOPBACK_FIND_TIMEOUT = 0.075
51
53
 
52
54
 
53
55
  class QuestionHistoryWithoutSuppression(QuestionHistory):
@@ -0,0 +1,68 @@
1
+ """Benchmark for the DNSCache record-count bound + overflow eviction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from itertools import count
7
+
8
+ from pytest_codspeed import BenchmarkFixture
9
+
10
+ from zeroconf import DNSAddress, DNSCache, current_time_millis
11
+ from zeroconf.const import _CLASS_IN, _MAX_CACHE_RECORDS, _TYPE_A
12
+
13
+
14
+ def _make_records(count_: int, now: float, prefix: str = "bench") -> list[DNSAddress]:
15
+ return [
16
+ DNSAddress(
17
+ f"{prefix}-{i}.local.",
18
+ _TYPE_A,
19
+ _CLASS_IN,
20
+ 120,
21
+ bytes(((i >> 24) & 0xFF, (i >> 16) & 0xFF, (i >> 8) & 0xFF, i & 0xFF)),
22
+ created=now + i,
23
+ )
24
+ for i in range(count_)
25
+ ]
26
+
27
+
28
+ def _unbounded_records(now: float, prefix: str = "evict") -> Iterator[DNSAddress]:
29
+ """Unbounded generator of unique-name DNSAddress records."""
30
+ for i in count():
31
+ yield DNSAddress(
32
+ f"{prefix}-{i}.local.",
33
+ _TYPE_A,
34
+ _CLASS_IN,
35
+ 120,
36
+ bytes(((i >> 24) & 0xFF, (i >> 16) & 0xFF, (i >> 8) & 0xFF, i & 0xFF)),
37
+ created=now + i,
38
+ )
39
+
40
+
41
+ def test_cache_add_below_cap(benchmark: BenchmarkFixture) -> None:
42
+ """Adding records while the cache is well below the cap (no eviction)."""
43
+ now = current_time_millis()
44
+ records = _make_records(1000, now)
45
+
46
+ @benchmark
47
+ def _add() -> None:
48
+ cache = DNSCache()
49
+ cache.async_add_records(records)
50
+
51
+
52
+ def test_cache_add_at_cap_evicts(benchmark: BenchmarkFixture) -> None:
53
+ """Steady-state add at the cap: every measured insert forces one eviction.
54
+
55
+ Pre-fills the cache to ``_MAX_CACHE_RECORDS`` outside the timed body so
56
+ only the eviction-path adds are measured. Each benchmark iteration
57
+ pulls one fresh unique record from an unbounded generator, keeping the
58
+ cache permanently at the cap. The generator avoids the iteration-count
59
+ cap that a pre-built pool would impose for very fast operations.
60
+ """
61
+ now = current_time_millis()
62
+ cache = DNSCache()
63
+ cache.async_add_records(_make_records(_MAX_CACHE_RECORDS, now, prefix="fill"))
64
+ pool = _unbounded_records(now + _MAX_CACHE_RECORDS)
65
+
66
+ @benchmark
67
+ def _evict_one() -> None:
68
+ cache.async_add_records([next(pool)])
@@ -10,6 +10,7 @@ import pytest
10
10
 
11
11
  from zeroconf import _core, const
12
12
  from zeroconf._handlers import query_handler
13
+ from zeroconf._services import browser as service_browser
13
14
  from zeroconf._services import info as service_info
14
15
 
15
16
 
@@ -46,18 +47,21 @@ def disable_duplicate_packet_suppression():
46
47
 
47
48
  @pytest.fixture
48
49
  def quick_timing() -> Generator[None]:
49
- """Shorten the probe/announce/goodbye intervals for tests on loopback.
50
+ """Shorten the probe/announce/goodbye/first-query intervals for tests on loopback.
50
51
 
51
52
  The production values (_CHECK_TIME=500ms, _REGISTER_TIME=225ms,
52
- _UNREGISTER_TIME=125ms) exist for RFC 6762 interop on real
53
- networks. Tests on 127.0.0.1 do not need them and pay 1-2s per
54
- register/unregister cycle without this fixture. Opt in by adding
55
- `quick_timing` to a test's argument list.
53
+ _UNREGISTER_TIME=125ms, _FIRST_QUERY_DELAY_RANDOM_INTERVAL=20-120ms)
54
+ exist for RFC 6762 interop on real networks (§8.1 thundering-herd
55
+ avoidance for probing, §5.2 for the initial-query delay). Tests on
56
+ 127.0.0.1 do not need them and pay 1-2s per register/unregister
57
+ cycle and 20-120ms per ServiceBrowser startup without this fixture.
58
+ Opt in by adding `quick_timing` to a test's argument list.
56
59
  """
57
60
  with (
58
61
  patch.object(_core, "_CHECK_TIME", 10),
59
62
  patch.object(_core, "_REGISTER_TIME", 10),
60
63
  patch.object(_core, "_UNREGISTER_TIME", 10),
64
+ patch.object(service_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (1, 5)),
61
65
  ):
62
66
  yield
63
67
 
@@ -28,7 +28,7 @@ def teardown_module():
28
28
  log.setLevel(original_logging_level)
29
29
 
30
30
 
31
- def test_integration_with_listener(disable_duplicate_packet_suppression):
31
+ def test_integration_with_listener(quick_timing, disable_duplicate_packet_suppression):
32
32
  type_ = "_test-listen-type._tcp.local."
33
33
  name = "xxxyyy"
34
34
  registration_name = f"{name}.{type_}"
@@ -59,7 +59,7 @@ def test_integration_with_listener(disable_duplicate_packet_suppression):
59
59
 
60
60
  @unittest.skipIf(not has_working_ipv6(), "Requires IPv6")
61
61
  @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled")
62
- def test_integration_with_listener_v6_records(disable_duplicate_packet_suppression):
62
+ def test_integration_with_listener_v6_records(quick_timing, disable_duplicate_packet_suppression):
63
63
  type_ = "_test-listenv6rec-type._tcp.local."
64
64
  name = "xxxyyy"
65
65
  registration_name = f"{name}.{type_}"
@@ -95,7 +95,7 @@ def test_integration_with_listener_v6_records(disable_duplicate_packet_suppressi
95
95
  sys.platform == "darwin" and os.environ.get("GITHUB_ACTIONS") == "true",
96
96
  "IPv6 multicast not working on macOS GitHub Actions",
97
97
  )
98
- def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression):
98
+ def test_integration_with_listener_ipv6(quick_timing, disable_duplicate_packet_suppression):
99
99
  type_ = "_test-listenv6ip-type._tcp.local."
100
100
  name = "xxxyyy"
101
101
  registration_name = f"{name}.{type_}"
@@ -127,7 +127,7 @@ def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression):
127
127
  zeroconf_registrar.close()
128
128
 
129
129
 
130
- def test_integration_with_subtype_and_listener(disable_duplicate_packet_suppression):
130
+ def test_integration_with_subtype_and_listener(quick_timing, disable_duplicate_packet_suppression):
131
131
  subtype_ = "_subtype._sub"
132
132
  type_ = "_listen._tcp.local."
133
133
  name = "xxxyyy"
@@ -439,7 +439,9 @@ async def test_cache_heap_multi_name_cleanup() -> None:
439
439
  )
440
440
  cache.async_add_records([record])
441
441
 
442
- assert len(cache._expire_heap) == min_records_to_cleanup + 5
442
+ # ``_async_add`` rebuilds ``_expire_heap`` proactively when stale entries
443
+ # dominate (heap > 2x expirations), so the heap is already capped at
444
+ # ~one entry per unique record long before ``async_expire`` is called.
443
445
  assert len(cache.async_entries_with_name(name)) == 1
444
446
  assert len(cache.async_entries_with_name(name2)) == 5
445
447
 
@@ -473,7 +475,8 @@ async def test_cache_heap_pops_order() -> None:
473
475
  )
474
476
  cache.async_add_records([record])
475
477
 
476
- assert len(cache._expire_heap) == min_records_to_cleanup + 5
478
+ # ``_async_add`` proactively rebuilds the heap when stale entries dominate,
479
+ # so the heap holds only one entry per unique record by this point.
477
480
  assert len(cache.async_entries_with_name(name)) == 1
478
481
  assert len(cache.async_entries_with_name(name2)) == 5
479
482
 
@@ -482,3 +485,237 @@ async def test_cache_heap_pops_order() -> None:
482
485
  ts, _ = heappop(cache._expire_heap)
483
486
  assert ts >= start_ts
484
487
  start_ts = ts
488
+
489
+
490
+ def _addr(name: str, idx: int, *, ttl: int = 120, created: float | None = None) -> r.DNSAddress:
491
+ """Build a DNSAddress with idx-derived payload for the bound/eviction tests."""
492
+ return r.DNSAddress(
493
+ name,
494
+ const._TYPE_A,
495
+ const._CLASS_IN,
496
+ ttl,
497
+ bytes((idx & 0xFF, (idx >> 8) & 0xFF, 0, 1)),
498
+ created=r.current_time_millis() if created is None else created,
499
+ )
500
+
501
+
502
+ def test_cache_size_is_bounded() -> None:
503
+ """A flood of unique-name records is capped at ``_MAX_CACHE_RECORDS``."""
504
+ cache = r.DNSCache()
505
+ now = r.current_time_millis()
506
+ overflow = 1000
507
+ flood_size = const._MAX_CACHE_RECORDS + overflow
508
+
509
+ cache.async_add_records(_addr(f"flood-{i}.local.", i, created=now + i) for i in range(flood_size))
510
+
511
+ total = sum(len(store) for store in cache.cache.values())
512
+ assert total == const._MAX_CACHE_RECORDS
513
+ assert cache._total_records == const._MAX_CACHE_RECORDS
514
+ # FIFO-ish: the earliest-created records (closest to expiration) get
515
+ # evicted first, so the names that remain are from the tail.
516
+ for i in range(overflow):
517
+ assert f"flood-{i}.local." not in cache.cache
518
+ for i in range(flood_size - overflow, flood_size):
519
+ assert f"flood-{i}.local." in cache.cache
520
+
521
+
522
+ def test_cache_eviction_empty_heap_returns_without_evicting() -> None:
523
+ """Eviction tolerates an empty ``_expire_heap`` (invariant-violation safety net)."""
524
+ cache = r.DNSCache()
525
+ # By the cache invariant every record in ``_total_records`` has a heap
526
+ # entry, so eviction should never see an empty heap. Force the broken
527
+ # state directly to pin the defensive behaviour: ``_async_evict_oldest``
528
+ # returns without raising and the subsequent insert still lands. Since
529
+ # eviction can't free space, the counter is allowed to drift past the
530
+ # cap by exactly one — pinned so a future change to the recovery
531
+ # semantics (e.g., refusing the add or clamping) fails this test.
532
+ cache._total_records = const._MAX_CACHE_RECORDS
533
+ cache._expire_heap = []
534
+ cache.async_add_records([_addr("post-empty.local.", 0)])
535
+ assert "post-empty.local." in cache.cache
536
+ assert cache._total_records == const._MAX_CACHE_RECORDS + 1
537
+
538
+
539
+ def test_cache_eviction_skips_stale_heap_entries() -> None:
540
+ """Eviction skips stale heap entries left by TTL re-adds."""
541
+ cache = r.DNSCache()
542
+ now = r.current_time_millis()
543
+ cache.async_add_records(
544
+ _addr(f"stale-{i}.local.", i, created=now + i) for i in range(const._MAX_CACHE_RECORDS)
545
+ )
546
+ assert cache._total_records == const._MAX_CACHE_RECORDS
547
+
548
+ # Re-add the closest-to-expiration record with a longer TTL; the prior
549
+ # ``(when, record)`` tuple stays as stale, eviction must skip it.
550
+ victim_name = "stale-0.local."
551
+ cache.async_add_records([_addr(victim_name, 0, ttl=7200, created=now)])
552
+ assert cache._total_records == const._MAX_CACHE_RECORDS
553
+
554
+ cache.async_add_records([_addr("trigger.local.", 0xFFFF, created=now + const._MAX_CACHE_RECORDS)])
555
+ assert cache._total_records == const._MAX_CACHE_RECORDS
556
+ assert victim_name in cache.cache
557
+ assert "stale-1.local." not in cache.cache
558
+
559
+
560
+ def test_cache_eviction_victim_shares_key_with_new_record() -> None:
561
+ """Inserting a record whose key collides with the eviction victim keeps it reachable."""
562
+ cache = r.DNSCache()
563
+ now = r.current_time_millis()
564
+ cache.async_add_records(
565
+ _addr(f"filler-{i}.local.", i, created=now + 1000 + i) for i in range(const._MAX_CACHE_RECORDS - 1)
566
+ )
567
+
568
+ # Insert at "shared.local." with the earliest expiration so eviction
569
+ # picks it. ``_remove_key`` then deletes ``cache["shared.local."]``.
570
+ shared_key = "shared.local."
571
+ cache.async_add_records([_addr(shared_key, 0x0102, created=now)])
572
+ assert cache._total_records == const._MAX_CACHE_RECORDS
573
+
574
+ # Adding a new record under the SAME key: a pre-eviction-captured
575
+ # ``store`` would write into an orphaned dict; the fix re-resolves.
576
+ new_shared = _addr(shared_key, 0x0506, created=now + 999)
577
+ cache.async_add_records([new_shared])
578
+
579
+ assert shared_key in cache.cache, "new record orphaned: cache bucket missing"
580
+ assert new_shared in cache.cache[shared_key]
581
+ assert cache.async_get_unique(new_shared) == new_shared
582
+ total = sum(len(store) for store in cache.cache.values())
583
+ assert total == cache._total_records
584
+
585
+
586
+ def test_cache_dnsnsec_at_cap_evicts_prior_record() -> None:
587
+ """A single DNSNsec arriving at the cap evicts one prior record and stays reachable."""
588
+ cache = r.DNSCache()
589
+ now = r.current_time_millis()
590
+ cache.async_add_records(
591
+ _addr(f"fill-{i}.local.", i, created=now + i) for i in range(const._MAX_CACHE_RECORDS)
592
+ )
593
+ assert cache._total_records == const._MAX_CACHE_RECORDS
594
+
595
+ nsec = r.DNSNsec(
596
+ "nsec-arrival.local.",
597
+ const._TYPE_NSEC,
598
+ const._CLASS_IN,
599
+ 120,
600
+ "nsec-arrival.local.",
601
+ [const._TYPE_A],
602
+ )
603
+ cache.async_add_records([nsec])
604
+
605
+ assert cache._total_records == const._MAX_CACHE_RECORDS
606
+ assert nsec in cache.cache[nsec.key]
607
+ # The earliest-created fill record is gone (FIFO-ish eviction).
608
+ assert "fill-0.local." not in cache.cache
609
+
610
+
611
+ def test_cache_dnsnsec_flood_is_bounded() -> None:
612
+ """DNSNsec records honour ``_MAX_CACHE_RECORDS`` (no bypass via the ``new`` flag)."""
613
+ cache = r.DNSCache()
614
+ overflow = 100
615
+ cache.async_add_records(
616
+ r.DNSNsec(
617
+ f"nsec-{i}.local.",
618
+ const._TYPE_NSEC,
619
+ const._CLASS_IN,
620
+ 120,
621
+ f"nsec-{i}.local.",
622
+ [const._TYPE_A],
623
+ )
624
+ for i in range(const._MAX_CACHE_RECORDS + overflow)
625
+ )
626
+ assert cache._total_records == const._MAX_CACHE_RECORDS
627
+ total = sum(len(store) for store in cache.cache.values())
628
+ assert total == const._MAX_CACHE_RECORDS
629
+
630
+
631
+ def test_cache_re_add_flood_does_not_grow_heap_unbounded() -> None:
632
+ """Replaying cached records with shifting TTLs cannot grow ``_expire_heap`` unbounded."""
633
+ cache = r.DNSCache()
634
+ now = r.current_time_millis()
635
+ # Stay below the cache cap so eviction never fires; the attack here is
636
+ # heap growth via re-add, not cap saturation. Clear the
637
+ # ``_MIN_SCHEDULED_RECORD_EXPIRATION`` floor so the rebuild engages.
638
+ record_count = 200
639
+ cache.async_add_records(_addr(f"flood-{i}.local.", i, created=now) for i in range(record_count))
640
+ assert cache._total_records == record_count
641
+
642
+ # 10 cycles x ``record_count`` stale pushes each. Without
643
+ # ``_maybe_rebuild_heap`` firing inside ``_async_add``, the heap would
644
+ # grow to ~11 x record_count.
645
+ for cycle in range(10):
646
+ cache.async_add_records(
647
+ _addr(f"flood-{i}.local.", i, ttl=7200 + cycle, created=now) for i in range(record_count)
648
+ )
649
+
650
+ # Heap is bounded near the rebuild threshold; ``+ record_count`` of slack
651
+ # to stay resilient to where in a re-add cycle the rebuild last fired.
652
+ assert len(cache._expire_heap) <= 2 * len(cache._expirations) + record_count
653
+ assert cache._total_records == record_count
654
+
655
+
656
+ def test_cache_eviction_decrements_total_records() -> None:
657
+ """Natural removal (goodbyes, expirations) keeps ``_total_records`` in sync."""
658
+ cache = r.DNSCache()
659
+ now = r.current_time_millis()
660
+ records = [_addr(f"sync-{i}.local.", i, created=now) for i in range(50)]
661
+ cache.async_add_records(records)
662
+ assert cache._total_records == 50
663
+
664
+ cache.async_remove_records(records[:20])
665
+ assert cache._total_records == 30
666
+
667
+ cache.async_expire(now + (200 * 1000))
668
+ assert cache._total_records == 0
669
+ assert not cache.cache
670
+
671
+
672
+ def test_cache_total_records_invariant_under_mixed_ops() -> None:
673
+ """``_total_records`` stays equal to the sum of bucket sizes across all touched paths."""
674
+ cache = r.DNSCache()
675
+ now = r.current_time_millis()
676
+
677
+ def actual() -> int:
678
+ return sum(len(store) for store in cache.cache.values())
679
+
680
+ addrs = [_addr(f"mix-{i}.local.", i, created=now + i) for i in range(20)]
681
+ cache.async_add_records(addrs)
682
+ assert cache._total_records == actual() == 20
683
+
684
+ # Re-add of an identical record: no increment.
685
+ cache.async_add_records([addrs[0]])
686
+ assert cache._total_records == actual() == 20
687
+
688
+ # DNSService writes service_cache too — counter still matches cache size.
689
+ svc = r.DNSService("svc.local.", const._TYPE_SRV, const._CLASS_IN, 120, 0, 0, 80, "host.local.")
690
+ cache.async_add_records([svc])
691
+ assert cache._total_records == actual() == 21
692
+ cache.async_remove_records([svc])
693
+ assert cache._total_records == actual() == 20
694
+
695
+ # DNSNsec is stored but excluded from the "new" return; counter tracks it anyway.
696
+ nsec = r.DNSNsec("nsec.local.", const._TYPE_NSEC, const._CLASS_IN, 120, "nsec.local.", [const._TYPE_A])
697
+ cache.async_add_records([nsec])
698
+ assert cache._total_records == actual() == 21
699
+ cache.async_remove_records([nsec])
700
+ assert cache._total_records == actual() == 20
701
+
702
+ # Shared-key insert/remove: emptying the bucket drops the cache key but
703
+ # counter decrements only by the records that left.
704
+ shared_a = _addr("shared.local.", 0x0101, created=now)
705
+ shared_b = _addr("shared.local.", 0x0202, created=now)
706
+ cache.async_add_records([shared_a, shared_b])
707
+ assert cache._total_records == actual() == 22
708
+ cache.async_remove_records([shared_a, shared_b])
709
+ assert cache._total_records == actual() == 20
710
+ assert "shared.local." not in cache.cache
711
+
712
+ cache.async_expire(now + (200 * 1000))
713
+ assert cache._total_records == actual() == 0
714
+ assert not cache.cache
715
+
716
+ # Full-cap eviction loop: counter never grows past the cap, never drifts.
717
+ cap_records = [_addr(f"cap-{i}.local.", i, created=now + i) for i in range(const._MAX_CACHE_RECORDS + 50)]
718
+ for rec in cap_records:
719
+ cache.async_add_records([rec])
720
+ assert cache._total_records == actual()
721
+ assert cache._total_records == const._MAX_CACHE_RECORDS
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes