From 473f25e19f25f041560d138069ee8e50b589a1b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 07:43:10 -1000 Subject: [PATCH 01/22] Implement duplicate question supression --- zeroconf/_core.py | 40 ++++++++++++++++++++++++++++++++--- zeroconf/_dns.py | 19 +++++++++-------- zeroconf/_handlers.py | 12 +++++++---- zeroconf/_services/browser.py | 8 +++++-- 4 files changed, 61 insertions(+), 18 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 1053103c0..89d8f8912 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -29,10 +29,10 @@ import sys import threading from types import TracebackType # noqa # used in type hints -from typing import Dict, List, Optional, Tuple, Type, Union, cast +from typing import Dict, List, Optional, Set, Tuple, Type, Union, cast from ._cache import DNSCache -from ._dns import DNSQuestion +from ._dns import DNSRecord, DNSQuestion from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager from ._logger import QuietLogger, log @@ -72,6 +72,38 @@ _TC_DELAY_RANDOM_INTERVAL = (400, 500) +class QuestionHistory: + def __init__(self) -> None: + self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} + + def add_question_at_time(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> None: + self._history[question] = (now, known_answers) + + def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: + previous_question = self._history.get(question) + # There was not previous question in the history + if not previous_question: + return False + than, previous_known_answers = previous_question + # The last question was older than 1s + if now - than > 1000: + return False + # The last question has more known answers than + # we knew so we have to ask + if previous_known_answers - known_answers: + return False + return True + + def async_expire(self, now: float) -> None: + removes = [ + question + for question, now_known_answers in self._history.items() + if now - now_known_answers[0] > 1000 + ] + for question in removes: + del self._history[question] + + class AsyncEngine: """An engine wraps sockets in the event loop.""" @@ -134,6 +166,7 @@ async def _async_cache_cleanup(self) -> None: """Periodic cache cleanup.""" while not self.zc.done: now = current_time_millis() + self.zc.question_history.async_expire(now) self.zc.record_manager.async_updates(now, self.zc.cache.async_expire(now)) self.zc.record_manager.async_updates_complete() await asyncio.sleep(millis_to_seconds(_CACHE_CLEANUP_INTERVAL)) @@ -288,7 +321,8 @@ def __init__( self.browsers: Dict[ServiceListener, ServiceBrowser] = {} self.registry = ServiceRegistry() self.cache = DNSCache() - self.query_handler = QueryHandler(self.registry, self.cache) + self.question_history = QuestionHistory() + self.query_handler = QueryHandler(self.registry, self.cache, self.question_history) self.record_manager = RecordManager(self) self.notify_event: Optional[asyncio.Event] = None diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index e656bc519..eb77873bf 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -21,7 +21,7 @@ """ import socket -from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING, Tuple, Union, cast +from typing import Any, Dict, Optional, Iterable, TYPE_CHECKING, Tuple, Union, cast from ._exceptions import AbstractMethodException from ._utils.net import _is_v6_address @@ -414,18 +414,19 @@ def __init__(self, records: Iterable[DNSRecord]) -> None: self._records = records self._lookup: Optional[Dict[DNSRecord, DNSRecord]] = None - def suppresses(self, record: DNSRecord) -> bool: - """Returns true if any answer in the rrset can suffice for the - information held in this record.""" + @property + def lookup(self) -> Dict[DNSRecord, DNSRecord]: if self._lookup is None: # Build the hash table so we can lookup the record independent of the ttl self._lookup = {record: record for record in self._records} - other = self._lookup.get(record) + return self._lookup + + def suppresses(self, record: DNSRecord) -> bool: + """Returns true if any answer in the rrset can suffice for the + information held in this record.""" + other = self.lookup.get(record) return bool(other and other.ttl > (record.ttl / 2)) def __contains__(self, record: DNSRecord) -> bool: """Returns true if the rrset contains the record.""" - if self._lookup is None: - # Build the hash table so we can lookup the record independent of the ttl - self._lookup = {record: record for record in self._records} - return record in self._lookup + return record in self.lookup diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 08c25e17e..3ac7e09ad 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -21,7 +21,7 @@ """ import itertools -from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast +from typing import Dict, Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union, cast from ._cache import DNSCache, _UniqueRecordsType from ._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord @@ -50,7 +50,7 @@ if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from ._core import Zeroconf # pylint: disable=cyclic-import + from ._core import QuestionHistory, Zeroconf # pylint: disable=cyclic-import _AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] @@ -156,10 +156,13 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: class QueryHandler: """Query the ServiceRegistry.""" - def __init__(self, registry: ServiceRegistry, cache: DNSCache) -> None: + def __init__( + self, registry: ServiceRegistry, cache: DNSCache, question_history: 'QuestionHistory' + ) -> None: """Init the query handler.""" self.registry = registry self.cache = cache + self.question_history = question_history def _add_service_type_enumeration_query_answers( self, answer_set: _AnswerWithAdditionalsType, known_answers: DNSRRSet, now: float @@ -253,6 +256,7 @@ def async_response( # pylint: disable=unused-argument for msg in msgs: for question in msg.questions: + self.question_history.add_question_at_time(question, msg.now, set(known_answers.lookup)) answer_set: _AnswerWithAdditionalsType = {} self._answer_question(question, answer_set, known_answers, msg.now) if not ucast_source and question.unicast: @@ -364,7 +368,7 @@ def async_updates_from_response(self, msg: DNSIncoming) -> None: self.async_updates_complete() def _async_mark_unique_cached_records_older_than_1s_to_expire( - self, unique_types: Set[Tuple[str, int, int]], answers: List[DNSRecord], now: float + self, unique_types: Set[Tuple[str, int, int]], answers: Iterable[DNSRecord], now: float ) -> None: # rfc6762#section-10.2 para 2 # Since unique is set, all old records with that name, rrtype, diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 296662d62..e2b244f23 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -59,7 +59,7 @@ if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from .._core import Zeroconf # pylint: disable=cyclic-import + from .._core import Zeroconf, QuestionHistory # pylint: disable=cyclic-import _QuestionWithKnownAnswers = Dict[DNSQuestion, Set[DNSPointer]] @@ -133,11 +133,15 @@ def generate_service_query( questions_with_known_answers: _QuestionWithKnownAnswers = {} for type_ in types_: question = DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) - questions_with_known_answers[question] = set( + known_answers = set( cast(DNSPointer, record) for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) if not record.is_stale(now) ) + if zc.question_history.suppresses(question, now, cast(Set[DNSRecord], known_answers)): + continue + questions_with_known_answers[question] = known_answers + return _group_ptr_queries_with_known_answers(now, multicast, questions_with_known_answers) From 5b338e9c3b00dab80364aa63b32614154c52f177 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 07:49:35 -1000 Subject: [PATCH 02/22] disable question history supression in backoff test --- tests/__init__.py | 1 + tests/services/test_browser.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/__init__.py b/tests/__init__.py index 86d7e1994..0e7aa930b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -65,3 +65,4 @@ def has_working_ipv6(): def _clear_cache(zc): zc.cache.cache.clear() + zc.question_history._history.clear() diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 5f2cea211..f4ffdb1c9 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -426,7 +426,8 @@ def _mock_get_expiration_time(self, percent): zeroconf.close() -def test_backoff(): +@unittest.mock.patch("zeroconf._core.QuestionHistory.suppresses", return_value=False) +def test_backoff(suppresses_mock): got_query = Event() type_ = "_http._tcp.local." From 999e189365f7b93bd703809334ac06fd29841e8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 07:52:46 -1000 Subject: [PATCH 03/22] debug log --- zeroconf/_services/browser.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index e2b244f23..0a1b6d759 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -31,6 +31,7 @@ from .._cache import _UniqueRecordsType from .._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRecord +from .._logger import log from .._protocol import DNSOutgoing from .._services import ( RecordUpdateListener, @@ -139,6 +140,7 @@ def generate_service_query( if not record.is_stale(now) ) if zc.question_history.suppresses(question, now, cast(Set[DNSRecord], known_answers)): + log.debug("Asking %s was suppressed by the question history", question) continue questions_with_known_answers[question] = known_answers From bc6a745ec28279fec948bc0b54e8507fdead00ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 07:55:46 -1000 Subject: [PATCH 04/22] QM questions only --- zeroconf/_core.py | 2 +- zeroconf/_dns.py | 2 +- zeroconf/_handlers.py | 3 ++- zeroconf/_services/browser.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 89d8f8912..1247e4ef9 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -32,7 +32,7 @@ from typing import Dict, List, Optional, Set, Tuple, Type, Union, cast from ._cache import DNSCache -from ._dns import DNSRecord, DNSQuestion +from ._dns import DNSQuestion, DNSRecord from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager from ._logger import QuietLogger, log diff --git a/zeroconf/_dns.py b/zeroconf/_dns.py index eb77873bf..0fad150dd 100644 --- a/zeroconf/_dns.py +++ b/zeroconf/_dns.py @@ -21,7 +21,7 @@ """ import socket -from typing import Any, Dict, Optional, Iterable, TYPE_CHECKING, Tuple, Union, cast +from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING, Tuple, Union, cast from ._exceptions import AbstractMethodException from ._utils.net import _is_v6_address diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index 3ac7e09ad..a797dafe3 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -256,7 +256,8 @@ def async_response( # pylint: disable=unused-argument for msg in msgs: for question in msg.questions: - self.question_history.add_question_at_time(question, msg.now, set(known_answers.lookup)) + if not question.unicast: + self.question_history.add_question_at_time(question, msg.now, set(known_answers.lookup)) answer_set: _AnswerWithAdditionalsType = {} self._answer_question(question, answer_set, known_answers, msg.now) if not ucast_source and question.unicast: diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 0a1b6d759..386e8f083 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -60,7 +60,7 @@ if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from .._core import Zeroconf, QuestionHistory # pylint: disable=cyclic-import + from .._core import Zeroconf # pylint: disable=cyclic-import _QuestionWithKnownAnswers = Dict[DNSQuestion, Set[DNSPointer]] From bec2ac881e74e4620eec65d7057c1f0adc9ca2d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 08:06:56 -1000 Subject: [PATCH 05/22] QM questions only --- zeroconf/_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 1247e4ef9..cd2119d64 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -85,8 +85,8 @@ def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRe if not previous_question: return False than, previous_known_answers = previous_question - # The last question was older than 1s - if now - than > 1000: + # The last question was older than 999ms + if now - than > 999: return False # The last question has more known answers than # we knew so we have to ask From 4598bdbc93f3873564c0f1d3b63a85a12a1dd031 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 08:08:05 -1000 Subject: [PATCH 06/22] QM questions only --- zeroconf/_core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index cd2119d64..f3868fc17 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -71,7 +71,7 @@ _TC_DELAY_RANDOM_INTERVAL = (400, 500) - +_DUPLICATE_QUESTION_INTERVAL = 999 # ms class QuestionHistory: def __init__(self) -> None: self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} @@ -86,7 +86,7 @@ def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRe return False than, previous_known_answers = previous_question # The last question was older than 999ms - if now - than > 999: + if now - than > _DUPLICATE_QUESTION_INTERVAL: return False # The last question has more known answers than # we knew so we have to ask @@ -98,7 +98,7 @@ def async_expire(self, now: float) -> None: removes = [ question for question, now_known_answers in self._history.items() - if now - now_known_answers[0] > 1000 + if now - now_known_answers[0] > _DUPLICATE_QUESTION_INTERVAL ] for question in removes: del self._history[question] From 0c4744b4368b0d833bbceb6f108ce69059b895f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 08:10:48 -1000 Subject: [PATCH 07/22] QM questions only --- zeroconf/_core.py | 3 ++- zeroconf/const.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index f3868fc17..64d548d75 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -57,6 +57,7 @@ _CHECK_TIME, _CLASS_IN, _CLASS_UNIQUE, + _DUPLICATE_QUESTION_INTERVAL, _FLAGS_AA, _FLAGS_QR_QUERY, _FLAGS_QR_RESPONSE, @@ -71,7 +72,7 @@ _TC_DELAY_RANDOM_INTERVAL = (400, 500) -_DUPLICATE_QUESTION_INTERVAL = 999 # ms + class QuestionHistory: def __init__(self) -> None: self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} diff --git a/zeroconf/const.py b/zeroconf/const.py index ba9d5309b..df1ba8be5 100644 --- a/zeroconf/const.py +++ b/zeroconf/const.py @@ -30,6 +30,7 @@ _REGISTER_TIME = 225 # ms _LISTENER_TIME = 200 # ms _BROWSER_TIME = 1000 # ms +_DUPLICATE_QUESTION_INTERVAL = _BROWSER_TIME - 1 # ms _BROWSER_BACKOFF_LIMIT = 3600 # s _CACHE_CLEANUP_INTERVAL = 10000 # ms From 908bdea45a9e2c57778e974b254c8cd842898de6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 08:29:45 -1000 Subject: [PATCH 08/22] replicate --- zeroconf/_core.py | 34 +------------------------ zeroconf/_handlers.py | 7 +++--- zeroconf/_history.py | 58 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 37 deletions(-) create mode 100644 zeroconf/_history.py diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 64d548d75..42fae46da 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -35,6 +35,7 @@ from ._dns import DNSQuestion, DNSRecord from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager +from ._history import QuestionHistory from ._logger import QuietLogger, log from ._protocol import DNSIncoming, DNSOutgoing from ._services import RecordUpdateListener, ServiceListener @@ -57,7 +58,6 @@ _CHECK_TIME, _CLASS_IN, _CLASS_UNIQUE, - _DUPLICATE_QUESTION_INTERVAL, _FLAGS_AA, _FLAGS_QR_QUERY, _FLAGS_QR_RESPONSE, @@ -73,38 +73,6 @@ _TC_DELAY_RANDOM_INTERVAL = (400, 500) -class QuestionHistory: - def __init__(self) -> None: - self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} - - def add_question_at_time(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> None: - self._history[question] = (now, known_answers) - - def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: - previous_question = self._history.get(question) - # There was not previous question in the history - if not previous_question: - return False - than, previous_known_answers = previous_question - # The last question was older than 999ms - if now - than > _DUPLICATE_QUESTION_INTERVAL: - return False - # The last question has more known answers than - # we knew so we have to ask - if previous_known_answers - known_answers: - return False - return True - - def async_expire(self, now: float) -> None: - removes = [ - question - for question, now_known_answers in self._history.items() - if now - now_known_answers[0] > _DUPLICATE_QUESTION_INTERVAL - ] - for question in removes: - del self._history[question] - - class AsyncEngine: """An engine wraps sockets in the event loop.""" diff --git a/zeroconf/_handlers.py b/zeroconf/_handlers.py index a797dafe3..79bb6f903 100644 --- a/zeroconf/_handlers.py +++ b/zeroconf/_handlers.py @@ -25,6 +25,7 @@ from ._cache import DNSCache, _UniqueRecordsType from ._dns import DNSAddress, DNSPointer, DNSQuestion, DNSRRSet, DNSRecord +from ._history import QuestionHistory from ._logger import log from ._protocol import DNSIncoming, DNSOutgoing from ._services import RecordUpdateListener @@ -50,7 +51,7 @@ if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 - from ._core import QuestionHistory, Zeroconf # pylint: disable=cyclic-import + from ._core import Zeroconf # pylint: disable=cyclic-import _AnswerWithAdditionalsType = Dict[DNSRecord, Set[DNSRecord]] @@ -156,9 +157,7 @@ def _has_mcast_record_in_last_second(self, record: DNSRecord) -> bool: class QueryHandler: """Query the ServiceRegistry.""" - def __init__( - self, registry: ServiceRegistry, cache: DNSCache, question_history: 'QuestionHistory' - ) -> None: + def __init__(self, registry: ServiceRegistry, cache: DNSCache, question_history: QuestionHistory) -> None: """Init the query handler.""" self.registry = registry self.cache = cache diff --git a/zeroconf/_history.py b/zeroconf/_history.py new file mode 100644 index 000000000..fc4cf8761 --- /dev/null +++ b/zeroconf/_history.py @@ -0,0 +1,58 @@ +""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine + Copyright 2003 Paul Scott-Murphy, 2014 William McBrine + + This module provides a framework for the use of DNS Service Discovery + using IP multicast. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 + USA +""" + +from typing import Dict, Set, Tuple + +from ._dns import DNSQuestion, DNSRecord +from .const import _DUPLICATE_QUESTION_INTERVAL + + +class QuestionHistory: + def __init__(self) -> None: + self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} + + def add_question_at_time(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> None: + self._history[question] = (now, known_answers) + + def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: + previous_question = self._history.get(question) + # There was not previous question in the history + if not previous_question: + return False + than, previous_known_answers = previous_question + # The last question was older than 999ms + if now - than > _DUPLICATE_QUESTION_INTERVAL: + return False + # The last question has more known answers than + # we knew so we have to ask + if previous_known_answers - known_answers: + return False + return True + + def async_expire(self, now: float) -> None: + removes = [ + question + for question, now_known_answers in self._history.items() + if now - now_known_answers[0] > _DUPLICATE_QUESTION_INTERVAL + ] + for question in removes: + del self._history[question] From 06001366f8c040aaac65e73d8175a3ac5dad837e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 08:32:30 -1000 Subject: [PATCH 09/22] imports --- zeroconf/_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zeroconf/_core.py b/zeroconf/_core.py index 42fae46da..83395f05e 100644 --- a/zeroconf/_core.py +++ b/zeroconf/_core.py @@ -29,10 +29,10 @@ import sys import threading from types import TracebackType # noqa # used in type hints -from typing import Dict, List, Optional, Set, Tuple, Type, Union, cast +from typing import Dict, List, Optional, Tuple, Type, Union, cast from ._cache import DNSCache -from ._dns import DNSQuestion, DNSRecord +from ._dns import DNSQuestion from ._exceptions import NonUniqueNameException from ._handlers import QueryHandler, RecordManager from ._history import QuestionHistory From 87aa36485e73e94f896aec0e52bd56e1a7fe02d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 08:38:47 -1000 Subject: [PATCH 10/22] comments --- zeroconf/_history.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/zeroconf/_history.py b/zeroconf/_history.py index fc4cf8761..90d191fce 100644 --- a/zeroconf/_history.py +++ b/zeroconf/_history.py @@ -25,6 +25,9 @@ from ._dns import DNSQuestion, DNSRecord from .const import _DUPLICATE_QUESTION_INTERVAL +# The QuestionHistory is used to implement Duplicate Question Suppression +# https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 + class QuestionHistory: def __init__(self) -> None: From d1197f49719e04c03de961077c048adc38f8fa0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 08:40:00 -1000 Subject: [PATCH 11/22] comments --- zeroconf/_history.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/zeroconf/_history.py b/zeroconf/_history.py index 90d191fce..cbb36144a 100644 --- a/zeroconf/_history.py +++ b/zeroconf/_history.py @@ -34,9 +34,17 @@ def __init__(self) -> None: self._history: Dict[DNSQuestion, Tuple[float, Set[DNSRecord]]] = {} def add_question_at_time(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> None: + """Remember a question with known answers.""" self._history[question] = (now, known_answers) def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRecord]) -> bool: + """Check to see if a question should be suppressed. + + https://datatracker.ietf.org/doc/html/rfc6762#section-7.3 + When multiple queriers on the network are querying + for the same resource records, there is no need for them to all be + repeatedly asking the same question. + """ previous_question = self._history.get(question) # There was not previous question in the history if not previous_question: @@ -52,6 +60,7 @@ def suppresses(self, question: DNSQuestion, now: float, known_answers: Set[DNSRe return True def async_expire(self, now: float) -> None: + """Expire the history of old questions.""" removes = [ question for question, now_known_answers in self._history.items() From 946cf908e5340208e50b3618a69e2af4472538e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 09:20:14 -1000 Subject: [PATCH 12/22] multicast only --- zeroconf/_services/browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 386e8f083..1123394cb 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -139,7 +139,7 @@ def generate_service_query( for record in zc.cache.get_all_by_details(type_, _TYPE_PTR, _CLASS_IN) if not record.is_stale(now) ) - if zc.question_history.suppresses(question, now, cast(Set[DNSRecord], known_answers)): + if multicast and zc.question_history.suppresses(question, now, cast(Set[DNSRecord], known_answers)): log.debug("Asking %s was suppressed by the question history", question) continue questions_with_known_answers[question] = known_answers From 523438bd6c6831964cef7f58daa4ecd724093335 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 09:25:36 -1000 Subject: [PATCH 13/22] Implement accidental synchronization protection (RFC2762 section 5.2) --- zeroconf/_services/browser.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 296662d62..8f80ce875 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -24,6 +24,7 @@ import concurrent.futures import contextlib import queue +import random import threading import warnings from collections import OrderedDict @@ -41,7 +42,7 @@ ) from .._utils.aio import get_best_available_queue, get_running_loop from .._utils.name import service_type_name -from .._utils.time import current_time_millis +from .._utils.time import current_time_millis, millis_to_seconds from ..const import ( _BROWSER_BACKOFF_LIMIT, _BROWSER_TIME, @@ -56,6 +57,8 @@ _TYPE_PTR, ) +# https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 +_FIRST_QUERY_DELAY_RANDOM_INTERVAL = (20, 120) # ms if TYPE_CHECKING: # https://github.com/PyCQA/pylint/issues/3525 @@ -190,14 +193,15 @@ def __init__( self.addr = addr self.port = port self.multicast = self.addr in (None, _MDNS_ADDR, _MDNS_ADDR6) - current_time = current_time_millis() - self._next_time = {check_type_: current_time for check_type_ in self.types} - self._delay = {check_type_: delay for check_type_ in self.types} + self._next_time: Dict[str, float] = {} + self._delay: Dict[str, float] = {check_type_: delay for check_type_ in self.types} self._pending_handlers: OrderedDict[Tuple[str, str], ServiceStateChange] = OrderedDict() self._service_state_changed = Signal() self.queue: Optional[queue.Queue] = None self.done = False + self._generate_first_next_time() + if hasattr(handlers, 'add_service'): listener = cast('ServiceListener', handlers) handlers = None @@ -212,6 +216,20 @@ def __init__( self.zc.add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) + def _generate_first_next_time(self): + """Generate the initial next query times. + + https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 + To avoid accidental synchronization when, for some reason, multiple + clients begin querying at exactly the same moment (e.g., because of + some common external trigger event), a Multicast DNS querier SHOULD + also delay the first query of the series by a randomly chosen amount + in the range 20-120 ms. + """ + delay = millis_to_seconds(random.randint(*_FIRST_QUERY_DELAY_RANDOM_INTERVAL)) + next_time = current_time_millis() + delay + self._next_time = {check_type_: next_time for check_type_ in self.types} + @property def service_state_changed(self) -> SignalRegistrationInterface: return self._service_state_changed.registration_interface From 15d824ab0a33d368fcfb57bd2e71ef600fedfa04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 09:44:49 -1000 Subject: [PATCH 14/22] cover --- tests/services/test_browser.py | 46 ++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 5f2cea211..df405ac22 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -14,7 +14,7 @@ import pytest import zeroconf as r -from zeroconf import DNSPointer, DNSQuestion, const, current_time_millis +from zeroconf import DNSPointer, DNSQuestion, const, current_time_millis, millis_to_seconds import zeroconf._services.browser as _services_browser from zeroconf import Zeroconf from zeroconf._services import ServiceStateChange @@ -453,7 +453,11 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): # patch the backoff limit to prevent test running forever with unittest.mock.patch.object(zeroconf_browser, "async_send", send), unittest.mock.patch.object( _services_browser, "current_time_millis", current_time_millis - ), unittest.mock.patch.object(_services_browser, "_BROWSER_BACKOFF_LIMIT", 10): + ), unittest.mock.patch.object( + _services_browser, "_BROWSER_BACKOFF_LIMIT", 10 + ), unittest.mock.patch.object( + _services_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (0, 0) + ): # dummy service callback def on_service_state_change(zeroconf, service_type, state_change, name): pass @@ -498,6 +502,44 @@ def on_service_state_change(zeroconf, service_type, state_change, name): zeroconf_browser.close() +def test_first_query_delay(): + """Verify the first query is delayed. + + https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 + """ + type_ = "_http._tcp.local." + zeroconf_browser = Zeroconf(interfaces=['127.0.0.1']) + + # we are going to patch the zeroconf send to check query transmission + old_send = zeroconf_browser.async_send + + first_query_time = None + + def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT): + """Sends an outgoing packet.""" + nonlocal first_query_time + if first_query_time is None: + first_query_time = current_time_millis() + old_send(out, addr=addr, port=port) + + # patch the zeroconf send + with unittest.mock.patch.object(zeroconf_browser, "async_send", send): + # dummy service callback + def on_service_state_change(zeroconf, service_type, state_change, name): + pass + + start_time = current_time_millis() + browser = ServiceBrowser(zeroconf_browser, type_, [on_service_state_change]) + time.sleep(millis_to_seconds(_services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[1] + 5)) + try: + assert ( + current_time_millis() - start_time > _services_browser._FIRST_QUERY_DELAY_RANDOM_INTERVAL[0] + ) + finally: + browser.cancel() + zeroconf_browser.close() + + def test_integration(): service_added = Event() service_removed = Event() From afe75303f7e1fe5093eef0f5e4ac81434d627867 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 11:00:42 -1000 Subject: [PATCH 15/22] typing --- zeroconf/_services/browser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 8f80ce875..964998a2c 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -216,7 +216,7 @@ def __init__( self.zc.add_listener(self, [DNSQuestion(type_, _TYPE_PTR, _CLASS_IN) for type_ in self.types]) - def _generate_first_next_time(self): + def _generate_first_next_time(self) -> None: """Generate the initial next query times. https://datatracker.ietf.org/doc/html/rfc6762#section-5.2 From e86687a259bf3fa2156e84c18c305d38dbf2f439 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 11:13:01 -1000 Subject: [PATCH 16/22] Add unit tests for history --- tests/test_history.py | 75 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/test_history.py diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 000000000..89159dff6 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +"""Unit tests for _history.py.""" + +from zeroconf._history import QuestionHistory +import zeroconf as r +import zeroconf.const as const + + +def test_question_suppression(): + history = QuestionHistory() + + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + now = r.current_time_millis() + other_known_answers = set( + [ + r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + ) + ] + ) + our_known_answers = set( + [ + r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-us._hap._tcp.local.' + ) + ] + ) + + history.add_question_at_time(question, now, other_known_answers) + + # Verify the question is suppressed if the known answers are the same + assert history.suppresses(question, now, other_known_answers) + + # Verify the question is suppressed if we know the answer to all the known answers + assert history.suppresses(question, now, other_known_answers | our_known_answers) + + # Verify the question is not suppressed if our known answers do no include the ones in the last question + assert not history.suppresses(question, now, set()) + + # Verify the question is not suppressed if our known answers do no include the ones in the last question + assert not history.suppresses(question, now, our_known_answers) + + # Verify the question is no longer suppressed after 1s + assert not history.suppresses(question, now + 1000, other_known_answers) + + +def test_question_expire(): + history = QuestionHistory() + + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + now = r.current_time_millis() + other_known_answers = set( + [ + r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + ) + ] + ) + history.add_question_at_time(question, now, other_known_answers) + + # Verify the question is suppressed if the known answers are the same + assert history.suppresses(question, now, other_known_answers) + + history.async_expire(now) + + # Verify the question is suppressed if the known answers are the same since the cache hasn't expired + assert history.suppresses(question, now, other_known_answers) + + history.async_expire(now + 1000) + + # Verify the question not longer suppressed since the cache has expired + assert not history.suppresses(question, now, other_known_answers) From da6817019aa5d3612d2d2abf89c74370130813fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 11:15:38 -1000 Subject: [PATCH 17/22] test that the reaper expires the question history --- tests/test_core.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 13c6ec706..c5bbf28e7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -50,10 +50,26 @@ async def test_reaper(): record_with_10s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 10, b'a') record_with_1s_ttl = r.DNSAddress('a', const._TYPE_SOA, const._CLASS_IN, 1, b'b') zeroconf.cache.async_add_records([record_with_10s_ttl, record_with_1s_ttl]) + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + now = r.current_time_millis() + other_known_answers = set( + [ + r.DNSPointer( + "_hap._tcp.local.", + const._TYPE_PTR, + const._CLASS_IN, + 10000, + 'known-to-other._hap._tcp.local.', + ) + ] + ) + zeroconf.question_history.add_question_at_time(question, now, other_known_answers) + assert zeroconf.question_history.suppresses(question, now, other_known_answers) entries_with_cache = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) await asyncio.sleep(1.2) entries = list(itertools.chain(*[cache.entries_with_name(name) for name in cache.names()])) await aiozc.async_close() + assert not zeroconf.question_history.suppresses(question, now, other_known_answers) assert entries != original_entries assert entries_with_cache != original_entries assert record_with_10s_ttl in entries From 7efa561fba5ef2ba67cd989f94c9da3fc8a7f10e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 11:20:03 -1000 Subject: [PATCH 18/22] test that the question history gets populated --- tests/test_handlers.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 36f252969..71d723c5d 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -960,3 +960,28 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.DNSRe assert set(updated) == set([ptr_record, a_record]) await aiozc.async_close() + + +def test_questions_query_handler_populates_the_question_history(): + zc = Zeroconf(interfaces=['127.0.0.1']) + now = current_time_millis() + _clear_cache(zc) + + # Test PTR supression + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + known_answer = r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + ) + generated.add_question(question) + generated.add_answer_at_time(known_answer, 0) + now = r.current_time_millis() + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is None + assert zc.question_history.suppresses(question, now, set([known_answer])) + + zc.close() From 7f8fe886fbcec055b68289ec23c0a97e34a67e66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 11:31:20 -1000 Subject: [PATCH 19/22] qu question test --- tests/test_handlers.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 71d723c5d..d77db3f03 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -962,14 +962,14 @@ def async_update_records(self, zc: 'Zeroconf', now: float, records: List[r.DNSRe await aiozc.async_close() -def test_questions_query_handler_populates_the_question_history(): +def test_questions_query_handler_populates_the_question_history_from_qm_questions(): zc = Zeroconf(interfaces=['127.0.0.1']) now = current_time_millis() _clear_cache(zc) - # Test PTR supression generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + question.unicast = False known_answer = r.DNSPointer( "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' ) @@ -985,3 +985,28 @@ def test_questions_query_handler_populates_the_question_history(): assert zc.question_history.suppresses(question, now, set([known_answer])) zc.close() + + +def test_questions_query_handler_does_not_put_qu_questions_in_history(): + zc = Zeroconf(interfaces=['127.0.0.1']) + now = current_time_millis() + _clear_cache(zc) + + generated = r.DNSOutgoing(const._FLAGS_QR_QUERY) + question = r.DNSQuestion("_hap._tcp._local.", const._TYPE_PTR, const._CLASS_IN) + question.unicast = True + known_answer = r.DNSPointer( + "_hap._tcp.local.", const._TYPE_PTR, const._CLASS_IN, 10000, 'known-to-other._hap._tcp.local.' + ) + generated.add_question(question) + generated.add_answer_at_time(known_answer, 0) + now = r.current_time_millis() + packets = generated.packets() + unicast_out, multicast_out = zc.query_handler.async_response( + [r.DNSIncoming(packet) for packet in packets], "1.2.3.4", const._MDNS_PORT + ) + assert unicast_out is None + assert multicast_out is None + assert not zc.question_history.suppresses(question, now, set([known_answer])) + + zc.close() From 16aa403824e390480c37c268ef939bd5ec8c97a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 11:42:17 -1000 Subject: [PATCH 20/22] Fix deadlock on ServiceBrowser shutdown with PyPy Fixes deadlock in ``` zeroconf/_services/browser.py:442: in cancel asyncio.run_coroutine_threadsafe(self._async_cancel_browser(), self.zc.loop).result() ``` --- zeroconf/_services/browser.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 964998a2c..2650d4ef5 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -386,7 +386,8 @@ async def _async_cancel_browser(self) -> None: self._browser_task.cancel() browser_task = self._browser_task self._browser_task = None - await browser_task + with contextlib.suppress(asyncio.CancelledError): + await browser_task class ServiceBrowser(_ServiceBrowserBase, threading.Thread): @@ -411,35 +412,30 @@ def __init__( self.queue = get_best_available_queue() self.daemon = True assert self.zc.loop is not None - if get_running_loop() == self.zc.loop: - self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) - else: - if not self.zc.loop.is_running(): - raise RuntimeError("The event loop is not running") - self._browser_task = cast( - asyncio.Task, - asyncio.run_coroutine_threadsafe(self._async_browser_task(), self.zc.loop).result(), - ) + if not self.zc.loop.is_running(): + raise RuntimeError("The event loop is not running") + self.zc.loop.call_soon_threadsafe(self._async_start_browser) self.start() self.name = "zeroconf-ServiceBrowser-%s-%s" % ( '-'.join([type_[:-7] for type_ in self.types]), getattr(self, 'native_id', self.ident), ) - async def _async_browser_task(self) -> asyncio.Task: - return cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) + def _async_start_browser(self) -> None: + """Start the browser from the event loop.""" + self._browser_task = cast(asyncio.Task, asyncio.ensure_future(self.async_browser_task())) + + def _async_cancel_browser_soon(self) -> None: + """Cancel the browser from the event loop.""" + if self._browser_task: + asyncio.ensure_future(self._async_cancel_browser()) def cancel(self) -> None: """Cancel the browser.""" assert self.zc.loop is not None assert self.queue is not None self.queue.put(None) - if self._browser_task: - if get_running_loop() == self.zc.loop: - asyncio.ensure_future(self._async_cancel_browser()) - elif self.zc.loop.is_running(): - with contextlib.suppress(asyncio.CancelledError, concurrent.futures.CancelledError): - asyncio.run_coroutine_threadsafe(self._async_cancel_browser(), self.zc.loop).result() + self.zc.loop.call_soon_threadsafe(self._async_cancel_browser_soon) super().cancel() self.join() From 11718b5b6a88105b2a3367a98ab18726c1c68db4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 11:55:45 -1000 Subject: [PATCH 21/22] test query generator --- tests/services/test_browser.py | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index e7c51936f..69c23cb0b 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -20,6 +20,7 @@ from zeroconf._services import ServiceStateChange from zeroconf._services.browser import ServiceBrowser from zeroconf._services.info import ServiceInfo +from zeroconf.aio import AsyncZeroconf from .. import has_working_ipv6, _inject_response @@ -903,3 +904,45 @@ def test_group_ptr_queries_with_known_answers(): # If we generate multiple packets there must # only be one question assert len(packets) == 1 or len(out.questions) == 1 + + +# This test uses asyncio because it needs to access the cache directly +# which is not threadsafe +@pytest.mark.asyncio +async def test_generate_service_query_suppress_duplicate_questions(): + """Generate a service query for sending with zeroconf.send.""" + aiozc = AsyncZeroconf(interfaces=['127.0.0.1']) + zc = aiozc.zeroconf + now = current_time_millis() + name = "_hap._tcp.local." + question = r.DNSQuestion(name, const._TYPE_PTR, const._CLASS_IN) + answer = r.DNSPointer( + name, + const._TYPE_PTR, + const._CLASS_IN, + 10000, + f'known-to-other.{name}', + ) + other_known_answers = set([answer]) + zc.question_history.add_question_at_time(question, now, other_known_answers) + assert zc.question_history.suppresses(question, now, other_known_answers) + + # The known answer list is different, do not suppress + outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) + assert outs + + zc.cache.async_add_records([answer]) + # The known answer list contains all the asked questions in the history + # we should suppress + + outs = _services_browser.generate_service_query(zc, now, [name], multicast=True) + assert not outs + + # We do not suppress once the question history expires + outs = _services_browser.generate_service_query(zc, now + 1000, [name], multicast=True) + assert outs + + # We do not suppress QU queries ever + outs = _services_browser.generate_service_query(zc, now, [name], multicast=False) + assert outs + await aiozc.async_close() From 56c81a1d34bdecf8a4519b5b73874ce39bddf319 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jun 2021 12:05:13 -1000 Subject: [PATCH 22/22] remove unused --- zeroconf/_services/browser.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/zeroconf/_services/browser.py b/zeroconf/_services/browser.py index 2650d4ef5..0bc926861 100644 --- a/zeroconf/_services/browser.py +++ b/zeroconf/_services/browser.py @@ -21,7 +21,6 @@ """ import asyncio -import concurrent.futures import contextlib import queue import random @@ -40,7 +39,7 @@ Signal, SignalRegistrationInterface, ) -from .._utils.aio import get_best_available_queue, get_running_loop +from .._utils.aio import get_best_available_queue from .._utils.name import service_type_name from .._utils.time import current_time_millis, millis_to_seconds from ..const import (