@@ -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,164 @@ 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 () -> None :
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 () -> None :
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 (const ._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 newest _RECENT_PACKETS_MAX entries are still in the
380+ # window; replaying them must be suppressed. Checked before
381+ # replaying the evicted ones below since that would mutate the
382+ # window and could mask an off-by-one in eviction.
383+ kept = packets [- const ._RECENT_PACKETS_MAX :]
384+ for packet in kept :
385+ listener ._process_datagram_at_time (False , len (packet ), now , packet , addrs )
386+ _handle_query_or_defer .assert_not_called ()
387+
388+ # The oldest packets should have been evicted and now replay.
389+ evicted = packets [: len (packets ) - const ._RECENT_PACKETS_MAX ]
390+ for packet in evicted :
391+ listener ._process_datagram_at_time (False , len (packet ), now , packet , addrs )
392+ assert _handle_query_or_defer .call_count == len (evicted )
393+
394+ zc .close ()
395+
396+
397+ def test_recent_packets_miss_with_small_now_is_not_suppressed () -> None :
398+ """A cache miss must not trigger suppression when `now` is below the suppression interval."""
399+ # time.monotonic() can start near zero on freshly booted systems, so
400+ # `now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL` is negative for the
401+ # first second of process lifetime. A 0.0 default on the recency
402+ # dict would let any negative `now - INTERVAL` satisfy the compare
403+ # and suppress legitimate traffic.
404+ zc = Zeroconf (interfaces = ["127.0.0.1" ])
405+ zc .registry .async_add (
406+ ServiceInfo (
407+ "_http._tcp.local." ,
408+ "Test._http._tcp.local." ,
409+ server = "Test._http._tcp.local." ,
410+ port = 4 ,
411+ )
412+ )
413+ zc .question_history = QuestionHistoryWithoutSuppression ()
414+
415+ class SubListener (_listener .AsyncListener ):
416+ def handle_query_or_defer (
417+ self ,
418+ msg : DNSIncoming ,
419+ addr : str ,
420+ port : int ,
421+ transport : _engine ._WrappedTransport ,
422+ v6_flow_scope : tuple [()] | tuple [int , int ] = (),
423+ ) -> None :
424+ super ().handle_query_or_defer (msg , addr , port , transport , v6_flow_scope )
425+
426+ listener = SubListener (zc )
427+ listener .transport = MagicMock ()
428+
429+ query = r .DNSOutgoing (const ._FLAGS_QR_QUERY , multicast = True )
430+ query .add_question (r .DNSQuestion ("a._http._tcp.local." , const ._TYPE_PTR , const ._CLASS_IN ))
431+ packet = query .packets ()[0 ]
432+
433+ addrs = ("1.2.3.4" , 43 )
434+
435+ with patch .object (listener , "handle_query_or_defer" ) as _handle_query_or_defer :
436+ listener ._process_datagram_at_time (False , len (packet ), 0.0 , packet , addrs )
437+ _handle_query_or_defer .assert_called_once ()
438+
439+ zc .close ()
0 commit comments