zeroconf 0.149.3__tar.gz → 0.149.5__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 (98) hide show
  1. {zeroconf-0.149.3 → zeroconf-0.149.5}/CHANGELOG.md +62 -0
  2. {zeroconf-0.149.3 → zeroconf-0.149.5}/PKG-INFO +1 -1
  3. {zeroconf-0.149.3 → zeroconf-0.149.5}/pyproject.toml +1 -1
  4. {zeroconf-0.149.3 → zeroconf-0.149.5}/setup.py +1 -1
  5. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/__init__.py +1 -1
  6. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_engine.py +29 -4
  7. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_protocol/incoming.pxd +1 -1
  8. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_protocol/incoming.py +10 -4
  9. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/__init__.py +31 -0
  10. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/conftest.py +19 -0
  11. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/services/test_browser.py +34 -13
  12. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/services/test_info.py +119 -126
  13. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/services/test_types.py +11 -9
  14. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_asyncio.py +45 -19
  15. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_core.py +16 -11
  16. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_engine.py +51 -3
  17. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_handlers.py +21 -6
  18. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_init.py +3 -0
  19. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_protocol.py +22 -0
  20. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_services.py +5 -3
  21. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_updates.py +1 -1
  22. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/utils/test_asyncio.py +1 -0
  23. {zeroconf-0.149.3 → zeroconf-0.149.5}/COPYING +0 -0
  24. {zeroconf-0.149.3 → zeroconf-0.149.5}/README.rst +0 -0
  25. {zeroconf-0.149.3 → zeroconf-0.149.5}/build_ext.py +0 -0
  26. {zeroconf-0.149.3 → zeroconf-0.149.5}/docs/Makefile +0 -0
  27. {zeroconf-0.149.3 → zeroconf-0.149.5}/docs/_ext/zeroconfautodocfix.py +0 -0
  28. {zeroconf-0.149.3 → zeroconf-0.149.5}/docs/api.rst +0 -0
  29. {zeroconf-0.149.3 → zeroconf-0.149.5}/docs/conf.py +0 -0
  30. {zeroconf-0.149.3 → zeroconf-0.149.5}/docs/index.rst +0 -0
  31. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_cache.pxd +0 -0
  32. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_cache.py +0 -0
  33. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_core.py +0 -0
  34. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_dns.pxd +0 -0
  35. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_dns.py +0 -0
  36. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_exceptions.py +0 -0
  37. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_handlers/__init__.py +0 -0
  38. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_handlers/answers.pxd +0 -0
  39. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_handlers/answers.py +0 -0
  40. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +0 -0
  41. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_handlers/multicast_outgoing_queue.py +0 -0
  42. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_handlers/query_handler.pxd +0 -0
  43. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_handlers/query_handler.py +0 -0
  44. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_handlers/record_manager.pxd +0 -0
  45. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_handlers/record_manager.py +0 -0
  46. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_history.pxd +0 -0
  47. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_history.py +0 -0
  48. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_listener.pxd +0 -0
  49. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_listener.py +0 -0
  50. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_logger.py +0 -0
  51. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_protocol/__init__.py +0 -0
  52. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_protocol/outgoing.pxd +0 -0
  53. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_protocol/outgoing.py +0 -0
  54. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_record_update.pxd +0 -0
  55. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_record_update.py +0 -0
  56. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_services/__init__.pxd +0 -0
  57. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_services/__init__.py +0 -0
  58. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_services/browser.pxd +0 -0
  59. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_services/browser.py +0 -0
  60. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_services/info.pxd +0 -0
  61. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_services/info.py +0 -0
  62. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_services/registry.pxd +0 -0
  63. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_services/registry.py +0 -0
  64. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_services/types.py +0 -0
  65. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_transport.py +0 -0
  66. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_updates.pxd +0 -0
  67. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_updates.py +0 -0
  68. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_utils/__init__.py +0 -0
  69. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_utils/asyncio.py +0 -0
  70. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_utils/ipaddress.pxd +0 -0
  71. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_utils/ipaddress.py +0 -0
  72. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_utils/name.py +0 -0
  73. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_utils/net.py +0 -0
  74. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_utils/time.pxd +0 -0
  75. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/_utils/time.py +0 -0
  76. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/asyncio.py +0 -0
  77. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/const.py +0 -0
  78. {zeroconf-0.149.3 → zeroconf-0.149.5}/src/zeroconf/py.typed +0 -0
  79. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/benchmarks/__init__.py +0 -0
  80. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/benchmarks/helpers.py +0 -0
  81. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/benchmarks/test_cache.py +0 -0
  82. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/benchmarks/test_incoming.py +0 -0
  83. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/benchmarks/test_outgoing.py +0 -0
  84. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/benchmarks/test_send.py +0 -0
  85. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/benchmarks/test_txt_properties.py +0 -0
  86. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/services/__init__.py +0 -0
  87. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/services/test_registry.py +0 -0
  88. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_cache.py +0 -0
  89. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_circular_imports.py +0 -0
  90. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_dns.py +0 -0
  91. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_exceptions.py +0 -0
  92. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_history.py +0 -0
  93. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_listener.py +0 -0
  94. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/test_logger.py +0 -0
  95. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/utils/__init__.py +0 -0
  96. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/utils/test_ipaddress.py +0 -0
  97. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/utils/test_name.py +0 -0
  98. {zeroconf-0.149.3 → zeroconf-0.149.5}/tests/utils/test_net.py +0 -0
@@ -2,6 +2,68 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.149.5 (2026-05-18)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Bound DNS compression-pointer chain depth in DNSIncoming
10
+ ([#1719](https://github.com/python-zeroconf/python-zeroconf/pull/1719),
11
+ [`f9e2359`](https://github.com/python-zeroconf/python-zeroconf/commit/f9e23592137f30fdf7ef710dba065da31c79b1cf))
12
+
13
+ ### Testing
14
+
15
+ - Cap helper get_service_info timeout in suppression test (~3.0s → ~1.85s)
16
+ ([#1708](https://github.com/python-zeroconf/python-zeroconf/pull/1708),
17
+ [`ee3c7d7`](https://github.com/python-zeroconf/python-zeroconf/commit/ee3c7d74ff45327a3a6d520b86a691e21e2bc219))
18
+
19
+ - Drop ZeroconfServiceTypes.find() timeouts from 500ms to 200ms on loopback
20
+ ([#1710](https://github.com/python-zeroconf/python-zeroconf/pull/1710),
21
+ [`64d143d`](https://github.com/python-zeroconf/python-zeroconf/commit/64d143d2ee7874ee1d9cef0dd2799c008b4aa791))
22
+
23
+ - Eliminate test_get_info_single race by injecting from the send mock
24
+ ([#1716](https://github.com/python-zeroconf/python-zeroconf/pull/1716),
25
+ [`963d3d7`](https://github.com/python-zeroconf/python-zeroconf/commit/963d3d70e1cde056967eba0d8747ddcd247ae707))
26
+
27
+ - Fix race in test_register_and_lookup_type_by_uppercase_name
28
+ ([#1712](https://github.com/python-zeroconf/python-zeroconf/pull/1712),
29
+ [`91aa21d`](https://github.com/python-zeroconf/python-zeroconf/commit/91aa21d52a0873f5fc12d43675b1b521dfe20519))
30
+
31
+ - Speed up service-info request tests with quick_request_timing fixture
32
+ ([#1709](https://github.com/python-zeroconf/python-zeroconf/pull/1709),
33
+ [`4bae30a`](https://github.com/python-zeroconf/python-zeroconf/commit/4bae30a2ed0910ee7c4f1d0f92f2c400a7b10f31))
34
+
35
+
36
+ ## v0.149.4 (2026-05-17)
37
+
38
+ ### Bug Fixes
39
+
40
+ - **core**: Release sockets when close runs before engine setup completes
41
+ ([#1706](https://github.com/python-zeroconf/python-zeroconf/pull/1706),
42
+ [`0deb56b`](https://github.com/python-zeroconf/python-zeroconf/commit/0deb56b78fe6cd701a43ce34dccd4b69d6dd6d36))
43
+
44
+ ### Testing
45
+
46
+ - Drop pending multicast responses before TOCTOU assertion
47
+ ([#1701](https://github.com/python-zeroconf/python-zeroconf/pull/1701),
48
+ [`d2058d9`](https://github.com/python-zeroconf/python-zeroconf/commit/d2058d95882c70fb2eb786d373a630314e656c15))
49
+
50
+ - Speed up slow loopback tests (closes #1697)
51
+ ([#1699](https://github.com/python-zeroconf/python-zeroconf/pull/1699),
52
+ [`dd341a3`](https://github.com/python-zeroconf/python-zeroconf/commit/dd341a378e904cbf9b9a09e5c2ac8ff7c9944cd0))
53
+
54
+ - Speed up slow loopback tests (closes #1700)
55
+ ([#1703](https://github.com/python-zeroconf/python-zeroconf/pull/1703),
56
+ [`d03ea36`](https://github.com/python-zeroconf/python-zeroconf/commit/d03ea364dea5866e0301ff11f6b78714ecf991e8))
57
+
58
+ - Speed up test_async_wait_unblocks_on_update
59
+ ([#1702](https://github.com/python-zeroconf/python-zeroconf/pull/1702),
60
+ [`653c385`](https://github.com/python-zeroconf/python-zeroconf/commit/653c38559c468672cf907d808d432dec0fb06968))
61
+
62
+ - Widen scheduling buffer in flaky get_info suppression test
63
+ ([#1698](https://github.com/python-zeroconf/python-zeroconf/pull/1698),
64
+ [`9b4db62`](https://github.com/python-zeroconf/python-zeroconf/commit/9b4db625e121c2d590ed8fe2f4d187d5e3bb9a73))
65
+
66
+
5
67
  ## v0.149.3 (2026-05-17)
6
68
 
7
69
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zeroconf
3
- Version: 0.149.3
3
+ Version: 0.149.5
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.3"
7
+ version = "0.149.5"
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.3',
22
+ 'version': '0.149.5',
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.3"
91
+ __version__ = "0.149.5"
92
92
  __license__ = "LGPL"
93
93
 
94
94
 
@@ -114,10 +114,17 @@ class AsyncEngine:
114
114
  lambda: AsyncListener(self.zc), # type: ignore[arg-type, return-value]
115
115
  sock=s,
116
116
  )
117
+ # Register the wrapped transport before releasing the engine's
118
+ # handle so a concurrent shutdown always sees ``s`` in exactly
119
+ # one place; do not add an ``await`` between these two steps.
117
120
  self.protocols.append(cast(AsyncListener, protocol))
118
121
  self.readers.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport)))
119
122
  if s in sender_sockets:
120
123
  self.senders.append(make_wrapped_transport(cast(asyncio.DatagramTransport, transport)))
124
+ if s is self._listen_socket:
125
+ self._listen_socket = None
126
+ if s in self._respond_sockets:
127
+ self._respond_sockets.remove(s)
121
128
 
122
129
  def _async_cache_cleanup(self) -> None:
123
130
  """Periodic cache cleanup."""
@@ -139,19 +146,37 @@ class AsyncEngine:
139
146
  async def _async_close(self) -> None:
140
147
  """Cancel and wait for the cleanup task to finish."""
141
148
  assert self._setup_task is not None
142
- await self._setup_task
149
+ # Swallow CancelledError only if the setup task itself was
150
+ # cancelled (close-before-start); outer-task cancellation must
151
+ # propagate.
152
+ try:
153
+ await self._setup_task
154
+ except asyncio.CancelledError:
155
+ if not self._setup_task.cancelled():
156
+ raise
143
157
  self._async_shutdown()
144
158
  await asyncio.sleep(0) # flush out any call soons
145
- assert self._cleanup_timer is not None
146
- self._cleanup_timer.cancel()
159
+ if self._cleanup_timer is not None:
160
+ self._cleanup_timer.cancel()
147
161
 
148
162
  def _async_shutdown(self) -> None:
149
- """Shutdown transports and sockets."""
163
+ """Shutdown transports and sockets; safe to call repeatedly."""
150
164
  assert self.running_future is not None
151
165
  assert self.loop is not None
152
166
  self.running_future = self.loop.create_future()
167
+ # Cancel pending setup so it can't wrap fresh transports after
168
+ # shutdown has started.
169
+ if self._setup_task is not None and not self._setup_task.done():
170
+ self._setup_task.cancel()
153
171
  for wrapped_transport in itertools.chain(self.senders, self.readers):
154
172
  wrapped_transport.transport.close()
173
+ # Anything still here was never adopted by a transport.
174
+ if self._listen_socket is not None:
175
+ self._listen_socket.close()
176
+ self._listen_socket = None
177
+ for s in self._respond_sockets:
178
+ s.close()
179
+ self._respond_sockets = []
155
180
 
156
181
  def close(self) -> None:
157
182
  """Close from sync context.
@@ -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)
@@ -60,7 +60,7 @@ 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
66
  _seen_logs: dict[str, int | tuple] = {}
@@ -409,7 +409,7 @@ class DNSIncoming:
409
409
  labels: list[str] = []
410
410
  seen_pointers: set[int] = set()
411
411
  original_offset = self.offset
412
- self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers)
412
+ self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers, 0)
413
413
  self._name_cache[original_offset] = labels
414
414
  name = ".".join(labels) + "."
415
415
  if len(name) > MAX_NAME_LENGTH:
@@ -418,8 +418,14 @@ class DNSIncoming:
418
418
  )
419
419
  return name
420
420
 
421
- def _decode_labels_at_offset(self, off: _int, labels: list[str], seen_pointers: set[int]) -> int:
421
+ def _decode_labels_at_offset(
422
+ self, off: _int, labels: list[str], seen_pointers: set[int], depth: _int
423
+ ) -> int:
422
424
  # This is a tight loop that is called frequently, small optimizations can make a difference.
425
+ if depth > MAX_DNS_LABELS:
426
+ raise IncomingDecodeError(
427
+ f"DNS compression pointer chain exceeds {MAX_DNS_LABELS} at {off} from {self.source}"
428
+ )
423
429
  view = self.view
424
430
  while off < self._data_len:
425
431
  length = view[off]
@@ -457,7 +463,7 @@ class DNSIncoming:
457
463
  if not linked_labels:
458
464
  linked_labels = []
459
465
  seen_pointers.add(link_py_int)
460
- self._decode_labels_at_offset(link, linked_labels, seen_pointers)
466
+ self._decode_labels_at_offset(link, linked_labels, seen_pointers, depth + 1)
461
467
  self._name_cache[link_py_int] = linked_labels
462
468
  labels.extend(linked_labels)
463
469
  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:
@@ -91,6 +105,23 @@ def _clear_cache(zc: Zeroconf) -> None:
91
105
  zc.question_history.clear()
92
106
 
93
107
 
108
+ def _backdate_cache(zc: Zeroconf, ms: int = 1100) -> None:
109
+ """Backdate every cached record's `created` time by `ms` milliseconds.
110
+
111
+ rfc6762#section-10.2 keys off "received more than one second ago", so
112
+ backdating is equivalent to sleeping `ms` in real time without the
113
+ wall-clock wait.
114
+
115
+ Iterate `store.values()`, not the dict directly — when a record is
116
+ re-added with an equal hash, the key stays the original object while
117
+ the value is replaced with the latest; mutating the key would update
118
+ stale objects no one reads.
119
+ """
120
+ for store in zc.cache.cache.values():
121
+ for record in store.values():
122
+ record.created -= ms
123
+
124
+
94
125
  def time_changed_millis(millis: float | None = None) -> None:
95
126
  """Call all scheduled events for a time."""
96
127
  loop = asyncio.get_running_loop()
@@ -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
@@ -331,7 +331,9 @@ class TestServiceBrowser(unittest.TestCase):
331
331
  service_updated_event.clear()
332
332
  service_text = b"path=/~matt2/"
333
333
  _inject_response(zeroconf, mock_record_update_incoming_msg(r.ServiceStateChange.Updated))
334
- service_updated_event.wait(wait_time)
334
+ # Negative assertion: a duplicate update must NOT fire the listener. The wait
335
+ # always times out, so keep the budget short rather than reusing wait_time.
336
+ service_updated_event.wait(0.3)
335
337
  assert service_added_count == 1
336
338
  assert service_updated_count == 2
337
339
  assert service_removed_count == 0
@@ -554,7 +556,7 @@ def test_first_query_delay():
554
556
 
555
557
 
556
558
  @pytest.mark.asyncio
557
- async def test_asking_default_is_asking_qm_questions_after_the_first_qu():
559
+ async def test_asking_default_is_asking_qm_questions_after_the_first_qu(quick_timing: None) -> None:
558
560
  """Verify the service browser's first questions are QU and refresh queries are QM."""
559
561
  service_added = asyncio.Event()
560
562
  service_removed = asyncio.Event()
@@ -656,7 +658,7 @@ async def test_asking_default_is_asking_qm_questions_after_the_first_qu():
656
658
 
657
659
 
658
660
  @pytest.mark.asyncio
659
- async def test_ttl_refresh_cancelled_rescue_query():
661
+ async def test_ttl_refresh_cancelled_rescue_query(quick_timing: None) -> None:
660
662
  """Verify seeing a name again cancels the rescue query."""
661
663
  service_added = asyncio.Event()
662
664
  service_removed = asyncio.Event()
@@ -844,7 +846,7 @@ async def test_asking_qu_questions():
844
846
  await aiozc.async_close()
845
847
 
846
848
 
847
- def test_legacy_record_update_listener():
849
+ def test_legacy_record_update_listener(quick_timing: None) -> None:
848
850
  """Test a RecordUpdateListener that does not implement update_records."""
849
851
 
850
852
  # instantiate a zeroconf instance
@@ -1031,7 +1033,7 @@ def test_service_browser_listeners_update_service():
1031
1033
 
1032
1034
 
1033
1035
  def test_service_browser_listeners_no_update_service():
1034
- """Test that the ServiceBrowser ServiceListener that does not implement update_service."""
1036
+ """A listener that ignores update events records only add/remove callbacks."""
1035
1037
 
1036
1038
  # instantiate a zeroconf instance
1037
1039
  zc = Zeroconf(interfaces=["127.0.0.1"])
@@ -1049,6 +1051,9 @@ def test_service_browser_listeners_no_update_service():
1049
1051
  if name == registration_name:
1050
1052
  callbacks.append(("remove", type_, name))
1051
1053
 
1054
+ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def]
1055
+ pass
1056
+
1052
1057
  listener = MyServiceListener()
1053
1058
 
1054
1059
  browser = r.ServiceBrowser(zc, type_, None, listener)
@@ -1497,10 +1502,15 @@ def test_service_browser_expire_callbacks():
1497
1502
  # Force the ttl to be 1 second
1498
1503
  now = current_time_millis()
1499
1504
  for cache_record in list(zc.cache.cache.values()):
1500
- for record in cache_record:
1505
+ for record in cache_record.values():
1501
1506
  zc.cache._async_set_created_ttl(record, now, 1)
1502
1507
 
1503
- time.sleep(0.3)
1508
+ # Wait for the add callback to fire from the original inject_response.
1509
+ for _ in range(30):
1510
+ time.sleep(0.01)
1511
+ if len(callbacks) == 1:
1512
+ break
1513
+
1504
1514
  info.port = 400
1505
1515
  info._dns_service_cache = None # we are mutating the record so clear the cache
1506
1516
 
@@ -1509,8 +1519,8 @@ def test_service_browser_expire_callbacks():
1509
1519
  mock_incoming_msg([info.dns_service()]),
1510
1520
  )
1511
1521
 
1512
- for _ in range(10):
1513
- time.sleep(0.05)
1522
+ for _ in range(30):
1523
+ time.sleep(0.01)
1514
1524
  if len(callbacks) == 2:
1515
1525
  break
1516
1526
 
@@ -1519,8 +1529,19 @@ def test_service_browser_expire_callbacks():
1519
1529
  ("update", type_, registration_name),
1520
1530
  ]
1521
1531
 
1522
- for _ in range(25):
1523
- time.sleep(0.05)
1532
+ # Re-add every cached record with `created` in the past so the
1533
+ # next reaper tick (0.01s) expires them and fires the remove
1534
+ # callback, instead of waiting the full TTL in real time.
1535
+ # Going through `_async_set_created_ttl` updates the expiration
1536
+ # heap; mutating `record.created` directly would leave the heap
1537
+ # entry pointing at the original `when` so the reaper never wakes.
1538
+ past = current_time_millis() - 2000
1539
+ for cache_record in list(zc.cache.cache.values()):
1540
+ for record in list(cache_record.values()):
1541
+ zc.cache._async_set_created_ttl(record, past, 1)
1542
+
1543
+ for _ in range(30):
1544
+ time.sleep(0.01)
1524
1545
  if len(callbacks) == 3:
1525
1546
  break
1526
1547
 
@@ -1565,7 +1586,7 @@ def test_scheduled_ptr_query_dunder_methods():
1565
1586
 
1566
1587
 
1567
1588
  @pytest.mark.asyncio
1568
- async def test_close_zeroconf_without_browser_before_start_up_queries():
1589
+ async def test_close_zeroconf_without_browser_before_start_up_queries(quick_timing: None) -> None:
1569
1590
  """Test that we stop sending startup queries if zeroconf is closed out from under the browser."""
1570
1591
  service_added = asyncio.Event()
1571
1592
  type_ = "_http._tcp.local."
@@ -1632,7 +1653,7 @@ async def test_close_zeroconf_without_browser_before_start_up_queries():
1632
1653
 
1633
1654
 
1634
1655
  @pytest.mark.asyncio
1635
- async def test_close_zeroconf_without_browser_after_start_up_queries():
1656
+ async def test_close_zeroconf_without_browser_after_start_up_queries(quick_timing: None) -> None:
1636
1657
  """Test that we stop sending rescue queries if zeroconf is closed out from under the browser."""
1637
1658
  service_added = asyncio.Event()
1638
1659