fix: bound duplicate-packet dedup against alternating-payload floods#1750
Conversation
6d73e18 to
642efe7
Compare
Merging this PR will improve performance by ×3.4
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | test_alternating_payloads |
1,349.5 µs | 105.8 µs | ×13 |
| 👁 | test_unique_payloads |
1.4 ms | 1.6 ms | -11.49% |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing fix/bounded-dedup-recency-window (56bb3a8) with master (304fae6)
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1750 +/- ##
=======================================
Coverage 99.76% 99.77%
=======================================
Files 33 33
Lines 3473 3486 +13
Branches 482 487 +5
=======================================
+ Hits 3465 3478 +13
Misses 5 5
Partials 3 3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
@bluetoothbot review |
642efe7 to
99cdade
Compare
PR Review — fix: bound duplicate-packet dedup against alternating-payload floodsSolid fix for a real issue. The single-slot dedup bypass via alternating payloads is correctly addressed by a bounded per-listener recency dict, the sentinel choice ( 🟢 Suggestions1. Document rationale for window size (`src/zeroconf/const.py`, L36)
Checklist
SummarySolid fix for a real issue. The single-slot dedup bypass via alternating payloads is correctly addressed by a bounded per-listener recency dict, the sentinel choice ( Automated review by Kōan303a453 |
There was a problem hiding this comment.
Pull request overview
This PR hardens AsyncListener._process_datagram_at_time duplicate-packet suppression to prevent trivial bypasses using alternating payloads, by adding a small bounded recency window per listener socket/interface.
Changes:
- Add per-listener
_recent_packetsbounded recency dict (max size_RECENT_PACKETS_MAX) to suppress duplicates even when alternating between a small set of payloads. - Update tests to validate alternating-payload suppression and bounded-window eviction, and reset the new state where tests rely on “fresh” listeners.
- Introduce
_RECENT_PACKETS_MAX = 16in constants and expose it to Cython via_listener.pxd.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/zeroconf/_listener.py |
Adds bounded recency duplicate suppression before parsing and populates the window for non-QU payloads. |
src/zeroconf/_listener.pxd |
Declares _RECENT_PACKETS_MAX and _recent_packets for the Cython-compiled listener and adds recent_time local typing. |
src/zeroconf/const.py |
Adds _RECENT_PACKETS_MAX constant controlling the window size. |
tests/test_listener.py |
Updates existing duplicate suppression test expectations and adds new tests for alternating payloads + eviction behavior. |
tests/test_handlers.py |
Clears _recent_packets between phases where tests manually bypass suppression. |
tests/__init__.py |
Extends _clear_cache() to reset per-listener dedup state between test phases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
99cdade to
303a453
Compare
|
@bluetoothbot review |
Replace the single-slot `self.data` remembered last packet with a bounded recency window keyed on payload bytes; an attacker alternating two byte-distinct payloads (A, B, A, B, ...) previously slipped past dedup on every packet, forcing every datagram through DNSIncoming parse and the deferred-queue dedup scan. State is kept on each AsyncListener (one per reader socket / interface) so a duplicate seen on one interface does not suppress a legitimate QU or unicast reply that arrives on another. Only non-QU payloads are cached so QU questions still get unicast replies on every receipt. The single-slot fast path is retained ahead of the dict check so the steady-state dedup hit stays a pure-C compare and matches the prior performance on `test_dedup_hit_same_payload`. Fixes #1724
303a453 to
56bb3a8
Compare
|
couldn't get rid of the 10% regression but it was better than the 54% regression that bot came up with. The check isn't free but it is cheap |
Summary
Single-slot dedup in
_process_datagram_at_timeonly compared againstself.data; alternating two byte-distinct payloads (A, B, A, B, ...) slipped past on every packet, forcing every datagram throughDNSIncomingparse and the deferred-queue scan. This adds a bounded per-listener recency window so the alternating-flood case is caught while keeping the same-packet fast path on the original code path.Details
_process_datagram_at_timekeeps the existingself.data == datasingle-slot check as the fast path so the steady-state same-payload hit stays a pure-C compare; on miss it consults a bounded_recent_packets: dict[bytes, float]keyed on payload bytes.recent_packets.get(data, 0.0)paired with(now - _DUPLICATE_PACKET_SUPPRESSION_INTERVAL) < recent_timekeeps the suppression compare a single C double compare under Cython; stored values are millisecond timestamps so 0.0 never collides with a real entry.has_qu_questionon hits.AsyncListener(one per reader socket / interface) so a duplicate seen on one interface does not suppress a legitimate QU or unicast reply that arrives on another._RECENT_PACKETS_MAXis added toconst.pyand cdef'd in_listener.pxd; eviction uses dict insertion order vianext(iter(recent_packets))so the oldest entry is dropped when the window is full.tests/_clear_cachenow also resets_recent_packetsand the single-slot state; tests that clear the cache between phases get a fresh listener view so legitimate retransmits inside the suppression window are not caught by the previous phase's window.Fixes #1724.
Test plan
SKIP_CYTHON=1 poetry run pytest tests/ --no-cov --timeout=60-- 368 passed.REQUIRE_CYTHON=1 poetry install --only=main,dev && poetry run pytest tests/ --no-cov --timeout=60-- 368 passed.tests/test_listener.py:test_guard_against_alternating_duplicate_packets-- pins the alternating-flood guard.test_recent_packets_window_is_bounded-- pins eviction of the oldest entries once the window fills.test_guard_against_duplicate_packetsupdated to assert bounded-window semantics where the old assertions encoded the single-slot bypass.poetry run cython -a -3 src/zeroconf/_listener.py-- the hit-path compare on line 145 stays score-0 (pure C double compare); the dict.get on line 144 is score-15 (__Pyx_PyDict_GetItemDefault+PyFloat_AsDouble).tests/benchmarks/test_listener_dedup.pyshapes vs master, 200 iters x 1000 runs:test_dedup_hit_same_payload: 6.4 us vs 6.4 us on master (no regression; fast path preserved).test_alternating_payloads: 8.7 us vs 78 us on master (~9x improvement; the issue).test_unique_payloads: 93 us vs 75 us on master (~90 ns per packet for the bounded-window bookkeeping).