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.
- {zeroconf-0.149.6 → zeroconf-0.149.7}/CHANGELOG.md +15 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/PKG-INFO +1 -1
- {zeroconf-0.149.6 → zeroconf-0.149.7}/pyproject.toml +1 -1
- {zeroconf-0.149.6 → zeroconf-0.149.7}/setup.py +1 -1
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/__init__.py +1 -1
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_cache.pxd +10 -1
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_cache.py +51 -28
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/const.py +6 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/__init__.py +5 -3
- zeroconf-0.149.7/tests/benchmarks/test_cache_bound.py +68 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/conftest.py +9 -5
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/services/test_types.py +4 -4
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_cache.py +239 -2
- {zeroconf-0.149.6 → zeroconf-0.149.7}/COPYING +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/README.rst +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/build_ext.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/docs/Makefile +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/docs/_ext/zeroconfautodocfix.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/docs/api.rst +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/docs/conf.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/docs/index.rst +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_core.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_dns.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_dns.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_engine.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_exceptions.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/__init__.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/answers.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/answers.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/multicast_outgoing_queue.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/query_handler.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/query_handler.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/record_manager.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_handlers/record_manager.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_history.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_history.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_listener.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_listener.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_logger.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_protocol/__init__.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_protocol/incoming.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_protocol/incoming.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_protocol/outgoing.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_protocol/outgoing.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_record_update.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_record_update.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/__init__.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/__init__.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/browser.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/browser.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/info.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/info.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/registry.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/registry.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_services/types.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_transport.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_updates.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_updates.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/__init__.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/asyncio.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/ipaddress.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/ipaddress.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/name.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/net.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/time.pxd +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/_utils/time.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/asyncio.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/src/zeroconf/py.typed +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/__init__.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/helpers.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_cache.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_incoming.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_mark_seen.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_outgoing.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_send.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/benchmarks/test_txt_properties.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/services/__init__.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/services/test_browser.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/services/test_info.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/services/test_registry.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_asyncio.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_circular_imports.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_core.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_dns.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_engine.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_exceptions.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_handlers.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_history.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_init.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_listener.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_logger.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_protocol.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_services.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/test_updates.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/utils/__init__.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/utils/test_asyncio.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/utils/test_ipaddress.py +0 -0
- {zeroconf-0.149.6 → zeroconf-0.149.7}/tests/utils/test_name.py +0 -0
- {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
|
|
@@ -19,7 +19,7 @@ install_requires = \
|
|
|
19
19
|
|
|
20
20
|
setup_kwargs = {
|
|
21
21
|
'name': 'zeroconf',
|
|
22
|
-
'version': '0.149.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
#
|
|
160
|
-
#
|
|
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
|
-
|
|
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.
|
|
49
|
-
#
|
|
50
|
-
|
|
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)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|