@@ -222,15 +222,16 @@ def handle_query_or_defer(
222222 _handle_query_or_defer .assert_called_once ()
223223 _handle_query_or_defer .reset_mock ()
224224
225- # Now call with the different packet and handle_query_or_defer should fire
225+ # Replay the first packet — the recency window remembers more than
226+ # just the most recent payload, so this is a duplicate.
226227 listener ._process_datagram_at_time (
227228 False ,
228229 len (packet_with_qm_question ),
229230 new_time ,
230231 packet_with_qm_question ,
231232 addrs ,
232233 )
233- _handle_query_or_defer .assert_called_once ()
234+ _handle_query_or_defer .assert_not_called ()
234235 _handle_query_or_defer .reset_mock ()
235236
236237 # Now call with the different packet with qu question and handle_query_or_defer should fire
@@ -257,18 +258,8 @@ def handle_query_or_defer(
257258
258259 log .setLevel (logging .WARNING )
259260
260- # Call with the QM packet again
261- listener ._process_datagram_at_time (
262- False ,
263- len (packet_with_qm_question ),
264- new_time ,
265- packet_with_qm_question ,
266- addrs ,
267- )
268- _handle_query_or_defer .assert_called_once ()
269- _handle_query_or_defer .reset_mock ()
270-
271- # Now call with the same packet again and handle_query_or_defer should not fire
261+ # Replay the QM packet with debug disabled — suppression must hold
262+ # off the debug-log path too.
272263 listener ._process_datagram_at_time (
273264 False ,
274265 len (packet_with_qm_question ),
@@ -285,3 +276,110 @@ def handle_query_or_defer(
285276 _handle_query_or_defer .reset_mock ()
286277
287278 zc .close ()
279+
280+
281+ def test_guard_against_alternating_duplicate_packets ():
282+ """Alternating two distinct payloads must not bypass duplicate suppression."""
283+ zc = Zeroconf (interfaces = ["127.0.0.1" ])
284+ zc .registry .async_add (
285+ ServiceInfo (
286+ "_http._tcp.local." ,
287+ "Test._http._tcp.local." ,
288+ server = "Test._http._tcp.local." ,
289+ port = 4 ,
290+ )
291+ )
292+ zc .question_history = QuestionHistoryWithoutSuppression ()
293+
294+ class SubListener (_listener .AsyncListener ):
295+ def handle_query_or_defer (
296+ self ,
297+ msg : DNSIncoming ,
298+ addr : str ,
299+ port : int ,
300+ transport : _engine ._WrappedTransport ,
301+ v6_flow_scope : tuple [()] | tuple [int , int ] = (),
302+ ) -> None :
303+ super ().handle_query_or_defer (msg , addr , port , transport , v6_flow_scope )
304+
305+ listener = SubListener (zc )
306+ listener .transport = MagicMock ()
307+
308+ query_a = r .DNSOutgoing (const ._FLAGS_QR_QUERY , multicast = True )
309+ query_a .add_question (r .DNSQuestion ("a._http._tcp.local." , const ._TYPE_PTR , const ._CLASS_IN ))
310+ packet_a = query_a .packets ()[0 ]
311+
312+ query_b = r .DNSOutgoing (const ._FLAGS_QR_QUERY , multicast = True )
313+ query_b .add_question (r .DNSQuestion ("b._http._tcp.local." , const ._TYPE_PTR , const ._CLASS_IN ))
314+ packet_b = query_b .packets ()[0 ]
315+
316+ assert packet_a != packet_b
317+
318+ addrs = ("1.2.3.4" , 43 )
319+
320+ with patch .object (listener , "handle_query_or_defer" ) as _handle_query_or_defer :
321+ now = current_time_millis ()
322+
323+ # Prime both payloads.
324+ listener ._process_datagram_at_time (False , len (packet_a ), now , packet_a , addrs )
325+ listener ._process_datagram_at_time (False , len (packet_b ), now , packet_b , addrs )
326+ assert _handle_query_or_defer .call_count == 2
327+ _handle_query_or_defer .reset_mock ()
328+
329+ for _ in range (4 ):
330+ listener ._process_datagram_at_time (False , len (packet_a ), now , packet_a , addrs )
331+ listener ._process_datagram_at_time (False , len (packet_b ), now , packet_b , addrs )
332+ _handle_query_or_defer .assert_not_called ()
333+
334+ zc .close ()
335+
336+
337+ def test_recent_packets_window_is_bounded ():
338+ """Distinct payloads beyond the recency window evict oldest entries."""
339+ zc = Zeroconf (interfaces = ["127.0.0.1" ])
340+ zc .registry .async_add (
341+ ServiceInfo (
342+ "_http._tcp.local." ,
343+ "Test._http._tcp.local." ,
344+ server = "Test._http._tcp.local." ,
345+ port = 4 ,
346+ )
347+ )
348+ zc .question_history = QuestionHistoryWithoutSuppression ()
349+
350+ class SubListener (_listener .AsyncListener ):
351+ def handle_query_or_defer (
352+ self ,
353+ msg : DNSIncoming ,
354+ addr : str ,
355+ port : int ,
356+ transport : _engine ._WrappedTransport ,
357+ v6_flow_scope : tuple [()] | tuple [int , int ] = (),
358+ ) -> None :
359+ super ().handle_query_or_defer (msg , addr , port , transport , v6_flow_scope )
360+
361+ listener = SubListener (zc )
362+ listener .transport = MagicMock ()
363+
364+ addrs = ("1.2.3.4" , 43 )
365+ now = current_time_millis ()
366+
367+ packets = []
368+ for i in range (_listener ._RECENT_PACKETS_MAX + 4 ):
369+ query = r .DNSOutgoing (const ._FLAGS_QR_QUERY , multicast = True )
370+ query .add_question (r .DNSQuestion (f"n{ i } ._http._tcp.local." , const ._TYPE_PTR , const ._CLASS_IN ))
371+ packets .append (query .packets ()[0 ])
372+
373+ with patch .object (listener , "handle_query_or_defer" ) as _handle_query_or_defer :
374+ for packet in packets :
375+ listener ._process_datagram_at_time (False , len (packet ), now , packet , addrs )
376+ assert _handle_query_or_defer .call_count == len (packets )
377+ _handle_query_or_defer .reset_mock ()
378+
379+ # The oldest packets should have been evicted and now replay.
380+ evicted = packets [: len (packets ) - _listener ._RECENT_PACKETS_MAX ]
381+ for packet in evicted :
382+ listener ._process_datagram_at_time (False , len (packet ), now , packet , addrs )
383+ assert _handle_query_or_defer .call_count == len (evicted )
384+
385+ zc .close ()
0 commit comments