zeroconf 0.149.4__tar.gz → 0.149.6__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.4 → zeroconf-0.149.6}/CHANGELOG.md +40 -0
  2. {zeroconf-0.149.4 → zeroconf-0.149.6}/PKG-INFO +1 -1
  3. {zeroconf-0.149.4 → zeroconf-0.149.6}/pyproject.toml +1 -1
  4. {zeroconf-0.149.4 → zeroconf-0.149.6}/setup.py +1 -1
  5. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/__init__.py +1 -1
  6. zeroconf-0.149.6/src/zeroconf/_logger.py +111 -0
  7. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_protocol/incoming.pxd +1 -1
  8. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_protocol/incoming.py +13 -13
  9. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/__init__.py +14 -0
  10. zeroconf-0.149.6/tests/benchmarks/test_mark_seen.py +39 -0
  11. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/conftest.py +19 -0
  12. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/services/test_info.py +84 -77
  13. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/services/test_types.py +11 -9
  14. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_asyncio.py +17 -9
  15. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_handlers.py +5 -1
  16. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_logger.py +82 -5
  17. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_protocol.py +56 -0
  18. zeroconf-0.149.4/src/zeroconf/_logger.py +0 -88
  19. {zeroconf-0.149.4 → zeroconf-0.149.6}/COPYING +0 -0
  20. {zeroconf-0.149.4 → zeroconf-0.149.6}/README.rst +0 -0
  21. {zeroconf-0.149.4 → zeroconf-0.149.6}/build_ext.py +0 -0
  22. {zeroconf-0.149.4 → zeroconf-0.149.6}/docs/Makefile +0 -0
  23. {zeroconf-0.149.4 → zeroconf-0.149.6}/docs/_ext/zeroconfautodocfix.py +0 -0
  24. {zeroconf-0.149.4 → zeroconf-0.149.6}/docs/api.rst +0 -0
  25. {zeroconf-0.149.4 → zeroconf-0.149.6}/docs/conf.py +0 -0
  26. {zeroconf-0.149.4 → zeroconf-0.149.6}/docs/index.rst +0 -0
  27. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_cache.pxd +0 -0
  28. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_cache.py +0 -0
  29. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_core.py +0 -0
  30. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_dns.pxd +0 -0
  31. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_dns.py +0 -0
  32. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_engine.py +0 -0
  33. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_exceptions.py +0 -0
  34. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_handlers/__init__.py +0 -0
  35. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_handlers/answers.pxd +0 -0
  36. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_handlers/answers.py +0 -0
  37. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +0 -0
  38. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_handlers/multicast_outgoing_queue.py +0 -0
  39. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_handlers/query_handler.pxd +0 -0
  40. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_handlers/query_handler.py +0 -0
  41. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_handlers/record_manager.pxd +0 -0
  42. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_handlers/record_manager.py +0 -0
  43. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_history.pxd +0 -0
  44. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_history.py +0 -0
  45. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_listener.pxd +0 -0
  46. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_listener.py +0 -0
  47. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_protocol/__init__.py +0 -0
  48. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_protocol/outgoing.pxd +0 -0
  49. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_protocol/outgoing.py +0 -0
  50. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_record_update.pxd +0 -0
  51. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_record_update.py +0 -0
  52. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_services/__init__.pxd +0 -0
  53. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_services/__init__.py +0 -0
  54. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_services/browser.pxd +0 -0
  55. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_services/browser.py +0 -0
  56. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_services/info.pxd +0 -0
  57. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_services/info.py +0 -0
  58. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_services/registry.pxd +0 -0
  59. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_services/registry.py +0 -0
  60. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_services/types.py +0 -0
  61. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_transport.py +0 -0
  62. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_updates.pxd +0 -0
  63. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_updates.py +0 -0
  64. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_utils/__init__.py +0 -0
  65. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_utils/asyncio.py +0 -0
  66. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_utils/ipaddress.pxd +0 -0
  67. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_utils/ipaddress.py +0 -0
  68. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_utils/name.py +0 -0
  69. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_utils/net.py +0 -0
  70. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_utils/time.pxd +0 -0
  71. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/_utils/time.py +0 -0
  72. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/asyncio.py +0 -0
  73. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/const.py +0 -0
  74. {zeroconf-0.149.4 → zeroconf-0.149.6}/src/zeroconf/py.typed +0 -0
  75. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/benchmarks/__init__.py +0 -0
  76. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/benchmarks/helpers.py +0 -0
  77. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/benchmarks/test_cache.py +0 -0
  78. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/benchmarks/test_incoming.py +0 -0
  79. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/benchmarks/test_outgoing.py +0 -0
  80. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/benchmarks/test_send.py +0 -0
  81. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/benchmarks/test_txt_properties.py +0 -0
  82. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/services/__init__.py +0 -0
  83. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/services/test_browser.py +0 -0
  84. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/services/test_registry.py +0 -0
  85. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_cache.py +0 -0
  86. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_circular_imports.py +0 -0
  87. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_core.py +0 -0
  88. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_dns.py +0 -0
  89. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_engine.py +0 -0
  90. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_exceptions.py +0 -0
  91. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_history.py +0 -0
  92. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_init.py +0 -0
  93. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_listener.py +0 -0
  94. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_services.py +0 -0
  95. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/test_updates.py +0 -0
  96. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/utils/__init__.py +0 -0
  97. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/utils/test_asyncio.py +0 -0
  98. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/utils/test_ipaddress.py +0 -0
  99. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/utils/test_name.py +0 -0
  100. {zeroconf-0.149.4 → zeroconf-0.149.6}/tests/utils/test_net.py +0 -0
@@ -2,6 +2,46 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.149.6 (2026-05-18)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Bound _seen_logs and stop retaining exc_info
10
+ ([#1717](https://github.com/python-zeroconf/python-zeroconf/pull/1717),
11
+ [`95561e2`](https://github.com/python-zeroconf/python-zeroconf/commit/95561e28b24922358f1991e38e3a86d70d72dcec))
12
+
13
+
14
+ ## v0.149.5 (2026-05-18)
15
+
16
+ ### Bug Fixes
17
+
18
+ - Bound DNS compression-pointer chain depth in DNSIncoming
19
+ ([#1719](https://github.com/python-zeroconf/python-zeroconf/pull/1719),
20
+ [`f9e2359`](https://github.com/python-zeroconf/python-zeroconf/commit/f9e23592137f30fdf7ef710dba065da31c79b1cf))
21
+
22
+ ### Testing
23
+
24
+ - Cap helper get_service_info timeout in suppression test (~3.0s → ~1.85s)
25
+ ([#1708](https://github.com/python-zeroconf/python-zeroconf/pull/1708),
26
+ [`ee3c7d7`](https://github.com/python-zeroconf/python-zeroconf/commit/ee3c7d74ff45327a3a6d520b86a691e21e2bc219))
27
+
28
+ - Drop ZeroconfServiceTypes.find() timeouts from 500ms to 200ms on loopback
29
+ ([#1710](https://github.com/python-zeroconf/python-zeroconf/pull/1710),
30
+ [`64d143d`](https://github.com/python-zeroconf/python-zeroconf/commit/64d143d2ee7874ee1d9cef0dd2799c008b4aa791))
31
+
32
+ - Eliminate test_get_info_single race by injecting from the send mock
33
+ ([#1716](https://github.com/python-zeroconf/python-zeroconf/pull/1716),
34
+ [`963d3d7`](https://github.com/python-zeroconf/python-zeroconf/commit/963d3d70e1cde056967eba0d8747ddcd247ae707))
35
+
36
+ - Fix race in test_register_and_lookup_type_by_uppercase_name
37
+ ([#1712](https://github.com/python-zeroconf/python-zeroconf/pull/1712),
38
+ [`91aa21d`](https://github.com/python-zeroconf/python-zeroconf/commit/91aa21d52a0873f5fc12d43675b1b521dfe20519))
39
+
40
+ - Speed up service-info request tests with quick_request_timing fixture
41
+ ([#1709](https://github.com/python-zeroconf/python-zeroconf/pull/1709),
42
+ [`4bae30a`](https://github.com/python-zeroconf/python-zeroconf/commit/4bae30a2ed0910ee7c4f1d0f92f2c400a7b10f31))
43
+
44
+
5
45
  ## v0.149.4 (2026-05-17)
6
46
 
7
47
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zeroconf
3
- Version: 0.149.4
3
+ Version: 0.149.6
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.4"
7
+ version = "0.149.6"
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.4',
22
+ 'version': '0.149.6',
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.4"
91
+ __version__ = "0.149.6"
92
92
  __license__ = "LGPL"
93
93
 
94
94
 
@@ -0,0 +1,111 @@
1
+ """Multicast DNS Service Discovery for Python, v0.14-wmcbrine
2
+ )
3
+ Copyright 2003 Paul Scott-Murphy, 2014 William McBrine
4
+
5
+ This module provides a framework for the use of DNS Service Discovery
6
+ using IP multicast.
7
+
8
+ This library is free software; you can redistribute it and/or
9
+ modify it under the terms of the GNU Lesser General Public
10
+ License as published by the Free Software Foundation; either
11
+ version 2.1 of the License, or (at your option) any later version.
12
+
13
+ This library is distributed in the hope that it will be useful,
14
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
15
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
16
+ Lesser General Public License for more details.
17
+
18
+ You should have received a copy of the GNU Lesser General Public
19
+ License along with this library; if not, write to the Free Software
20
+ Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301
21
+ USA
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import sys
28
+ from typing import Any
29
+
30
+ log = logging.getLogger(__name__.split(".", maxsplit=1)[0])
31
+ log.addHandler(logging.NullHandler())
32
+
33
+
34
+ def set_logger_level_if_unset() -> None:
35
+ if log.level == logging.NOTSET:
36
+ log.setLevel(logging.WARN)
37
+
38
+
39
+ set_logger_level_if_unset()
40
+
41
+
42
+ _MAX_SEEN_LOGS = 512
43
+ _seen_logs: dict[str, None] = {}
44
+
45
+
46
+ def _evict_oldest(seen: dict[str, None]) -> bool:
47
+ """Pop the oldest entry from ``seen``; return False if it raced.
48
+
49
+ Individual dict ops (``pop`` with a default, ``next``) are atomic
50
+ on the free-threaded build, but the compound ``iter`` → ``next``
51
+ used to pick the FIFO victim can raise ``RuntimeError`` if
52
+ another thread mutates the dict between the two ops. The caller
53
+ breaks its drain loop on False so concurrent mutation can't make
54
+ it spin.
55
+ """
56
+ try:
57
+ seen.pop(next(iter(seen)), None)
58
+ except (RuntimeError, StopIteration):
59
+ return False
60
+ return True
61
+
62
+
63
+ def _mark_seen(seen: dict[str, None], key: str) -> bool:
64
+ """Record ``key`` in ``seen`` and return True if it was newly added.
65
+
66
+ Bounds the dict so callers passing attacker-influenced keys (peer
67
+ addresses, packet offsets) cannot grow it without bound. Evicts
68
+ the oldest entries on overflow (dict preserves insertion order on
69
+ Python 3.7+), so ``_MAX_SEEN_LOGS`` is a recency window.
70
+
71
+ The dict is shared across all ``Zeroconf`` instances in the
72
+ process; on the free-threaded build (3.14t) and under multi-
73
+ instance sync use, callers can race the ``len < cap`` check and
74
+ both insert, leaving the dict transiently above the cap. The
75
+ drain loop runs on every call (steady-state-at-cap hits are a
76
+ single ``len`` + compare past the membership check because the
77
+ helper short-circuits) so a contention burst is corrected by the
78
+ next caller regardless of whether it's a hit or a miss.
79
+ """
80
+ inserting = key not in seen
81
+ # Hit (``inserting`` is False): drain only if drifted above cap.
82
+ # Miss (``inserting`` is True): drain to ``cap - 1`` to make room
83
+ # for the new key. Bool subtracts as 0/1 to pick the right limit.
84
+ while len(seen) > _MAX_SEEN_LOGS - inserting and _evict_oldest(seen):
85
+ pass
86
+ if inserting:
87
+ seen[key] = None
88
+ return inserting
89
+
90
+
91
+ class QuietLogger:
92
+ @classmethod
93
+ def log_exception_warning(cls, *logger_data: Any) -> None:
94
+ first_time = _mark_seen(_seen_logs, str(sys.exc_info()[1]))
95
+ logger = log.warning if first_time else log.debug
96
+ logger(*(logger_data or ["Exception occurred"]), exc_info=True)
97
+
98
+ @classmethod
99
+ def log_exception_debug(cls, *logger_data: Any) -> None:
100
+ first_time = _mark_seen(_seen_logs, str(sys.exc_info()[1]))
101
+ log.debug(*(logger_data or ["Exception occurred"]), exc_info=first_time)
102
+
103
+ @classmethod
104
+ def log_warning_once(cls, *args: Any) -> None:
105
+ logger = log.warning if _mark_seen(_seen_logs, args[0]) else log.debug
106
+ logger(*args)
107
+
108
+ @classmethod
109
+ def log_exception_once(cls, exc: Exception, *args: Any) -> None:
110
+ logger = log.warning if _mark_seen(_seen_logs, args[0]) else log.debug
111
+ logger(*args, exc_info=exc)
@@ -83,7 +83,7 @@ cdef class DNSIncoming:
83
83
  link_py_int=object,
84
84
  linked_labels=cython.list
85
85
  )
86
- cdef unsigned int _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers)
86
+ cdef unsigned int _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers, unsigned int depth)
87
87
 
88
88
  @cython.locals(offset="unsigned int")
89
89
  cdef void _read_header(self)
@@ -37,7 +37,7 @@ from .._dns import (
37
37
  DNSText,
38
38
  )
39
39
  from .._exceptions import IncomingDecodeError
40
- from .._logger import log
40
+ from .._logger import _mark_seen, log
41
41
  from .._utils.time import current_time_millis
42
42
  from ..const import (
43
43
  _FLAGS_QR_MASK,
@@ -60,10 +60,10 @@ DNS_COMPRESSION_POINTER_LEN = 2
60
60
  MAX_DNS_LABELS = 128
61
61
  MAX_NAME_LENGTH = 253
62
62
 
63
- DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError)
63
+ DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError, RecursionError)
64
64
 
65
65
 
66
- _seen_logs: dict[str, int | tuple] = {}
66
+ _seen_logs: dict[str, None] = {}
67
67
  _str = str
68
68
  _int = int
69
69
 
@@ -182,13 +182,7 @@ class DNSIncoming:
182
182
 
183
183
  @classmethod
184
184
  def _log_exception_debug(cls, *logger_data: Any) -> None:
185
- log_exc_info = False
186
- exc_info = sys.exc_info()
187
- exc_str = str(exc_info[1])
188
- if exc_str not in _seen_logs:
189
- # log the trace only on the first time
190
- _seen_logs[exc_str] = exc_info
191
- log_exc_info = True
185
+ log_exc_info = _mark_seen(_seen_logs, str(sys.exc_info()[1]))
192
186
  log.debug(*(logger_data or ["Exception occurred"]), exc_info=log_exc_info)
193
187
 
194
188
  def answers(self) -> list[DNSRecord]:
@@ -409,7 +403,7 @@ class DNSIncoming:
409
403
  labels: list[str] = []
410
404
  seen_pointers: set[int] = set()
411
405
  original_offset = self.offset
412
- self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers)
406
+ self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers, 0)
413
407
  self._name_cache[original_offset] = labels
414
408
  name = ".".join(labels) + "."
415
409
  if len(name) > MAX_NAME_LENGTH:
@@ -418,8 +412,14 @@ class DNSIncoming:
418
412
  )
419
413
  return name
420
414
 
421
- def _decode_labels_at_offset(self, off: _int, labels: list[str], seen_pointers: set[int]) -> int:
415
+ def _decode_labels_at_offset(
416
+ self, off: _int, labels: list[str], seen_pointers: set[int], depth: _int
417
+ ) -> int:
422
418
  # This is a tight loop that is called frequently, small optimizations can make a difference.
419
+ if depth > MAX_DNS_LABELS:
420
+ raise IncomingDecodeError(
421
+ f"DNS compression pointer chain exceeds {MAX_DNS_LABELS} at {off} from {self.source}"
422
+ )
423
423
  view = self.view
424
424
  while off < self._data_len:
425
425
  length = view[off]
@@ -457,7 +457,7 @@ class DNSIncoming:
457
457
  if not linked_labels:
458
458
  linked_labels = []
459
459
  seen_pointers.add(link_py_int)
460
- self._decode_labels_at_offset(link, linked_labels, seen_pointers)
460
+ self._decode_labels_at_offset(link, linked_labels, seen_pointers, depth + 1)
461
461
  self._name_cache[link_py_int] = linked_labels
462
462
  labels.extend(linked_labels)
463
463
  if len(labels) > MAX_DNS_LABELS:
@@ -35,6 +35,20 @@ from zeroconf._history import QuestionHistory
35
35
 
36
36
  _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution
37
37
 
38
+ # get_service_info / async_request timeout for tests using the
39
+ # `quick_request_timing` fixture. The fixture cuts the initial-query
40
+ # delay to ~15ms (10ms _LISTENER_TIME + 1-5ms jitter), so 50ms is
41
+ # ample headroom for tests that only need to observe the first one
42
+ # or two queries.
43
+ QUICK_REQUEST_TIMEOUT_MS = 50
44
+
45
+ # Timeout for ZeroconfServiceTypes.find() / AsyncZeroconfServiceTypes.async_find()
46
+ # in loopback integration tests. `find()` is just `time.sleep(timeout)` —
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
51
+
38
52
 
39
53
  class QuestionHistoryWithoutSuppression(QuestionHistory):
40
54
  def suppresses(self, question: DNSQuestion, now: float, known_answers: set[DNSRecord]) -> bool:
@@ -0,0 +1,39 @@
1
+ """Benchmark for _logger._mark_seen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pytest_codspeed import BenchmarkFixture
6
+
7
+ from zeroconf._logger import _MAX_SEEN_LOGS, _mark_seen
8
+
9
+
10
+ def test_mark_seen_hit(benchmark: BenchmarkFixture) -> None:
11
+ """Benchmark the cache-hit path (same key repeated)."""
12
+ seen: dict[str, None] = {"warm": None}
13
+
14
+ @benchmark
15
+ def _hit() -> None:
16
+ for _ in range(1000):
17
+ _mark_seen(seen, "warm")
18
+
19
+
20
+ def test_mark_seen_fill(benchmark: BenchmarkFixture) -> None:
21
+ """Benchmark filling from empty up to the cap (no evictions)."""
22
+ keys = [f"key-{i}" for i in range(_MAX_SEEN_LOGS)]
23
+
24
+ @benchmark
25
+ def _fill() -> None:
26
+ seen: dict[str, None] = {}
27
+ for k in keys:
28
+ _mark_seen(seen, k)
29
+
30
+
31
+ def test_mark_seen_churn(benchmark: BenchmarkFixture) -> None:
32
+ """Benchmark sustained eviction (every call past the cap drops oldest)."""
33
+ keys = [f"churn-{i}" for i in range(_MAX_SEEN_LOGS * 4)]
34
+
35
+ @benchmark
36
+ def _churn() -> None:
37
+ seen: dict[str, None] = {}
38
+ for k in keys:
39
+ _mark_seen(seen, k)
@@ -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 info as service_info
13
14
 
14
15
 
15
16
  @pytest.fixture(autouse=True)
@@ -59,3 +60,21 @@ def quick_timing() -> Generator[None]:
59
60
  patch.object(_core, "_UNREGISTER_TIME", 10),
60
61
  ):
61
62
  yield
63
+
64
+
65
+ @pytest.fixture
66
+ def quick_request_timing() -> Generator[None]:
67
+ """Shorten the initial-query delay used by AsyncServiceInfo.async_request.
68
+
69
+ The 200ms `_LISTENER_TIME` and 20-120ms random jitter (RFC 6762
70
+ §5.2) help spread queries from multiple clients on real networks.
71
+ On loopback they're pure overhead — get_service_info-style tests
72
+ wait ~250ms before the first query even fires. Opt in by adding
73
+ `quick_request_timing` to a test's argument list, then drop the
74
+ test's own timeouts (which had to accommodate that delay).
75
+ """
76
+ with (
77
+ patch.object(service_info, "_LISTENER_TIME", 10),
78
+ patch.object(service_info, "_AVOID_SYNC_DELAY_RANDOM_INTERVAL", (1, 5)),
79
+ ):
80
+ yield
@@ -23,7 +23,7 @@ from zeroconf._services.info import ServiceInfo
23
23
  from zeroconf._utils.net import IPVersion
24
24
  from zeroconf.asyncio import AsyncZeroconf
25
25
 
26
- from .. import _inject_response, has_working_ipv6
26
+ from .. import QUICK_REQUEST_TIMEOUT_MS, _inject_response, has_working_ipv6
27
27
 
28
28
  log = logging.getLogger("zeroconf")
29
29
  original_logging_level = logging.NOTSET
@@ -430,9 +430,9 @@ class TestServiceInfo(unittest.TestCase):
430
430
 
431
431
  return r.DNSIncoming(generated.packets()[0])
432
432
 
433
- def get_service_info_helper(zc, type, name):
433
+ def get_service_info_helper(zc, type, name, timeout):
434
434
  nonlocal service_info
435
- service_info = zc.get_service_info(type, name)
435
+ service_info = zc.get_service_info(type, name, timeout)
436
436
  service_info_event.set()
437
437
 
438
438
  try:
@@ -453,9 +453,14 @@ class TestServiceInfo(unittest.TestCase):
453
453
  for question in seed_history_questions:
454
454
  zc.question_history.add_question_at_time(question, far_future, set())
455
455
 
456
+ # No answers ever come back (all queries are suppressed),
457
+ # so cap the helper at the worst-case sum of the three
458
+ # phase waits below plus margin instead of the 3000ms
459
+ # default. Phase 3 waits ~1.6s (the 999ms QM gap plus
460
+ # jitter and 500ms buffer); 1500ms covers it.
456
461
  helper_thread = threading.Thread(
457
462
  target=get_service_info_helper,
458
- args=(zc, service_type, service_name),
463
+ args=(zc, service_type, service_name, 1500),
459
464
  )
460
465
  helper_thread.start()
461
466
  wait_time = (const._LISTENER_TIME + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 500) / 1000
@@ -506,6 +511,7 @@ class TestServiceInfo(unittest.TestCase):
506
511
  zc.remove_all_service_listeners()
507
512
  zc.close()
508
513
 
514
+ @pytest.mark.usefixtures("quick_request_timing")
509
515
  def test_get_info_single(self):
510
516
  zc = r.Zeroconf(interfaces=["127.0.0.1"])
511
517
 
@@ -516,91 +522,88 @@ class TestServiceInfo(unittest.TestCase):
516
522
  service_address = "10.0.1.2"
517
523
 
518
524
  service_info = None
519
- send_event = Event()
520
525
  service_info_event = Event()
521
526
 
522
- last_sent: r.DNSOutgoing | None = None
527
+ ttl = 120
528
+ response_records = [
529
+ r.DNSText(
530
+ service_name,
531
+ const._TYPE_TXT,
532
+ const._CLASS_IN | const._CLASS_UNIQUE,
533
+ ttl,
534
+ service_text,
535
+ ),
536
+ r.DNSService(
537
+ service_name,
538
+ const._TYPE_SRV,
539
+ const._CLASS_IN | const._CLASS_UNIQUE,
540
+ ttl,
541
+ 0,
542
+ 0,
543
+ 80,
544
+ service_server,
545
+ ),
546
+ r.DNSAddress(
547
+ service_server,
548
+ const._TYPE_A,
549
+ const._CLASS_IN | const._CLASS_UNIQUE,
550
+ ttl,
551
+ socket.inet_pton(socket.AF_INET, service_address),
552
+ ),
553
+ ]
523
554
 
524
- def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
525
- """Sends an outgoing packet."""
526
- nonlocal last_sent
555
+ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming:
556
+ generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE)
557
+ for record in records:
558
+ generated.add_answer_at_time(record, 0)
559
+ return r.DNSIncoming(generated.packets()[0])
527
560
 
528
- last_sent = out
529
- send_event.set()
561
+ sent_queries: list[r.DNSOutgoing] = []
562
+
563
+ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
564
+ """Capture each query and, on the first one, fill the cache
565
+ inline so the next iteration of `async_request` finds
566
+ `_is_complete=True` and exits without sending another query.
567
+
568
+ Running the inject from inside `send` keeps it on the event
569
+ loop thread and atomic with the first send — eliminating the
570
+ test-thread → `run_coroutine_threadsafe` race that flaked
571
+ under PyPy + use_cython when `quick_request_timing` shortens
572
+ the inter-iteration delay to ~15ms.
573
+ """
574
+ sent_queries.append(out)
575
+ if len(sent_queries) == 1:
576
+ zc.record_manager.async_updates_from_response(mock_incoming_msg(response_records))
577
+
578
+ def get_service_info_helper(zc, type, name):
579
+ nonlocal service_info
580
+ service_info = zc.get_service_info(type, name)
581
+ service_info_event.set()
530
582
 
531
583
  # patch the zeroconf send
532
584
  with patch.object(zc, "async_send", send):
533
-
534
- def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming:
535
- generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE)
536
-
537
- for record in records:
538
- generated.add_answer_at_time(record, 0)
539
-
540
- return r.DNSIncoming(generated.packets()[0])
541
-
542
- def get_service_info_helper(zc, type, name):
543
- nonlocal service_info
544
- service_info = zc.get_service_info(type, name)
545
- service_info_event.set()
546
-
547
585
  try:
548
- ttl = 120
549
586
  helper_thread = threading.Thread(
550
587
  target=get_service_info_helper,
551
588
  args=(zc, service_type, service_name),
552
589
  )
553
590
  helper_thread.start()
554
- wait_time = 1
555
591
 
556
- # Expect query for SRV, TXT, A, AAAA
557
- send_event.wait(wait_time)
558
- assert last_sent is not None
559
- assert len(last_sent.questions) == 4
560
- assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions
561
- assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions
562
- assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions
563
- assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions
564
- assert service_info is None
565
-
566
- # Expect no further queries
567
- last_sent = None
568
- send_event.clear()
569
- _inject_response(
570
- zc,
571
- mock_incoming_msg(
572
- [
573
- r.DNSText(
574
- service_name,
575
- const._TYPE_TXT,
576
- const._CLASS_IN | const._CLASS_UNIQUE,
577
- ttl,
578
- service_text,
579
- ),
580
- r.DNSService(
581
- service_name,
582
- const._TYPE_SRV,
583
- const._CLASS_IN | const._CLASS_UNIQUE,
584
- ttl,
585
- 0,
586
- 0,
587
- 80,
588
- service_server,
589
- ),
590
- r.DNSAddress(
591
- service_server,
592
- const._TYPE_A,
593
- const._CLASS_IN | const._CLASS_UNIQUE,
594
- ttl,
595
- socket.inet_pton(socket.AF_INET, service_address),
596
- ),
597
- ]
598
- ),
599
- )
600
- send_event.wait(wait_time)
601
- assert last_sent is None
592
+ # Helper should complete promptly — the inline inject in
593
+ # `send` populates the cache before the request loop's
594
+ # next iteration.
595
+ service_info_event.wait(1)
602
596
  assert service_info is not None
603
597
 
598
+ # First (and only) query: QU for SRV/TXT/A/AAAA.
599
+ assert len(sent_queries) == 1
600
+ first_sent = sent_queries[0]
601
+ assert len(first_sent.questions) == 4
602
+ assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in first_sent.questions
603
+ assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in first_sent.questions
604
+ assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in first_sent.questions
605
+ assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in first_sent.questions
606
+
604
607
  finally:
605
608
  helper_thread.join()
606
609
  zc.remove_all_service_listeners()
@@ -980,7 +983,7 @@ def test_serviceinfo_accepts_bytes_or_string_dict():
980
983
  assert info_service.dns_text().text == b"\x0epath=/~paulsm/"
981
984
 
982
985
 
983
- def test_asking_qu_questions():
986
+ def test_asking_qu_questions(quick_request_timing):
984
987
  """Verify explicitly asking QU questions."""
985
988
  type_ = "_quservice._tcp.local."
986
989
  zeroconf = r.Zeroconf(interfaces=["127.0.0.1"])
@@ -999,12 +1002,14 @@ def test_asking_qu_questions():
999
1002
 
1000
1003
  # patch the zeroconf send
1001
1004
  with patch.object(zeroconf, "async_send", send):
1002
- zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QU)
1005
+ zeroconf.get_service_info(
1006
+ f"name.{type_}", type_, QUICK_REQUEST_TIMEOUT_MS, question_type=r.DNSQuestionType.QU
1007
+ )
1003
1008
  assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr]
1004
1009
  zeroconf.close()
1005
1010
 
1006
1011
 
1007
- def test_asking_qm_questions():
1012
+ def test_asking_qm_questions(quick_request_timing):
1008
1013
  """Verify explicitly asking QM questions."""
1009
1014
  type_ = "_quservice._tcp.local."
1010
1015
  zeroconf = r.Zeroconf(interfaces=["127.0.0.1"])
@@ -1023,7 +1028,9 @@ def test_asking_qm_questions():
1023
1028
 
1024
1029
  # patch the zeroconf send
1025
1030
  with patch.object(zeroconf, "async_send", send):
1026
- zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QM)
1031
+ zeroconf.get_service_info(
1032
+ f"name.{type_}", type_, QUICK_REQUEST_TIMEOUT_MS, question_type=r.DNSQuestionType.QM
1033
+ )
1027
1034
  assert first_outgoing.questions[0].unicast is False # type: ignore[union-attr]
1028
1035
  zeroconf.close()
1029
1036