test(kad_dht): cover the per-query FIND_NODE timeout against a silent peer - #1435
test(kad_dht): cover the per-query FIND_NODE timeout against a silent peer#1435yashksaini-coder wants to merge 7 commits into
Conversation
_query_peer_for_closest reads a peer's response with stream.read() and no timeout, so a peer that opens the query stream but never replies blocks the lookup nursery forever — hanging find_peer, provide, find_providers and routing-table refresh. Wrap the per-peer query in move_on_after(QUERY_TIMEOUT) at the shared _query_single_peer_for_closest choke point, mirroring the guard provider_store and value_store already use. Add a regression test that a silent peer no longer hangs the query. Closes libp2p#1434
acul71
left a comment
There was a problem hiding this comment.
Recommended path
The hang in #1434 is already fixed on main by 96151e12 (inner move_on_after(QUERY_TIMEOUT) in _query_peer_for_closest). Please reshape this PR as follows:
- Drop the redundant outer
move_on_afterwrapper in_query_single_peer_for_closest(and the incorrect comments that say the inner path has no timeout). - Keep a silent-peer regression test, but point it at
_query_peer_for_closest(the real I/O choke point). - Reword or drop
newsfragments/1434.bugfix.rstso the changelog does not claim this PR introduced the only timeout. If96151e12never got a user-facing note, a single accurate bugfix fragment is fine; otherwise drop the duplicate. - Close #1434 as already fixed by
96151e12(or retarget it only if you want a follow-up for boundingstream.close()/stream.reset()infinally— that is a separate, smaller change).
Optional follow-up (not required for this PR): bound await stream.close() with its own short timeout (or reset() on query timeout) instead of stacking two identical QUERY_TIMEOUT scopes.
Review: #1435 — fix(kad_dht): bound network lookups with a per-query timeout
1. Summary of Changes
This PR intends to fix #1434: a silent/malicious peer that opens a Kademlia query stream but never replies would hang find_peer, provide, find_providers, and routing-table refresh because peer_routing._query_peer_for_closest allegedly read the response with no timeout.
What the PR actually changes:
- Wraps
_query_single_peer_for_closestintrio.move_on_after(QUERY_TIMEOUT)and logs when the cancel scope is caught. - Adds
newsfragments/1434.bugfix.rst. - Adds a unit test that a silent peer (
stream.read→trio.sleep_forever) does not hang_query_single_peer_for_closest.
Modules: libp2p/kad_dht/peer_routing.py (Kademlia iterative FIND_NODE lookup), plus tests and a newsfragment. No public API, deprecations, or breaking changes.
Critical context: the hang described in #1434 is already fixed on main. Commit 96151e12 (2026-08-03, 10 days before the issue) wrapped _query_peer_for_closest itself in trio.move_on_after(QUERY_TIMEOUT). The issue text still talks about unbounded stream.read() around line 338; current main reads via read_varint_prefixed_bytes_limited inside that inner timeout. This PR’s comments, description, and newsfragment do not reflect that.
2. Branch Sync Status and Merge Conflicts
Branch Sync Status
- Status: Ahead of
origin/main(not behind). - Details:
0behind,6ahead (one functional commit plus five merges ofmain).
Merge Conflict Analysis
✅ No merge conflicts detected. The PR branch can be merged cleanly into origin/main.
3. Strengths
- Chooses the existing
QUERY_TIMEOUTconstant and Triomove_on_after/cancelled_caughtAPIs already used inprovider_storeandkad_dht. - Regression test uses
autojump_clockplus an outerfail_after, so it will not stall CI if the query is unbounded. - Newsfragment is present, named
1434.bugfix.rst, ReST-formatted, user-facing, and ends with a newline. - PR body links the issue (
Closes #1434). - GitHub Actions for this PR are fully green (Linux tox matrix, Windows, docs).
4. Issues Found
Critical
None. The added wrapper is not a logic regression; it is largely redundant with code already on main.
Major
- File:
libp2p/kad_dht/peer_routing.py - Line(s): 158–180
- Issue: The stated bug is already fixed.
_query_peer_for_closestalready bounds the stream open/write/read intrio.move_on_after(QUERY_TIMEOUT)(lines 375–416). The new outer wrapper does not add a second, independent timeout budget for the silent-peer hang, and the comments claiming_query_peer_for_closesthas no timeout are false. - Suggestion: Follow the recommended path above (drop wrapper; keep test on the inner method).
# Bound the whole per-peer exchange: _query_peer_for_closest reads
# the response with stream.read() and no timeout, so a peer that
# opens the stream but never replies would otherwise block this
# query — and the surrounding lookup nursery — forever. Mirror the
# move_on_after(QUERY_TIMEOUT) guard already used in provider_store
# and value_store.
with trio.move_on_after(QUERY_TIMEOUT) as cancel_scope:
result = await self._query_peer_for_closest(peer, target_key)
# ...
if cancel_scope.cancelled_caught:
logger.debug(
"Query to peer %s timed out after %ss", peer, QUERY_TIMEOUT
)Already on main (lines 361–416):
async def _query_peer_for_closest(self, peer: ID, target_key: bytes) -> list[ID]:
"""
Query a peer for their closest peers to the target key using varint
length prefix. Each operation has a timeout to prevent hanging on
unresponsive peers.
"""
# ...
try:
with trio.move_on_after(QUERY_TIMEOUT):
# ...
stream = await self.host.new_stream(peer, [PROTOCOL_ID])
# ...
response_bytes = await read_varint_prefixed_bytes_limited(
stream, MAX_DHT_MESSAGE_SIZE
)-
File:
newsfragments/1434.bugfix.rst -
Line(s): 1
-
Issue: Changelog text tells users that lookups could previously block indefinitely and that this PR bounds them. On current
mainthey are already bounded. Shipping this fragment as-is over-claims the user-visible change. -
Suggestion: Reword to a single accurate user-facing note if
96151e12never got changelog credit; otherwise drop the duplicate. -
File:
tests/core/kad_dht/test_unit_peer_routing.py -
Line(s): 462–491
-
Issue: The new test would pass on
mainwithout this PR, because the innermove_on_afteralready cancelsstream.read. It does not prove the new wrapper is what prevents the hang. -
Suggestion: Keep a silent-peer regression test, but target
_query_peer_for_closestdirectly.
Minor
-
File:
libp2p/kad_dht/peer_routing.py -
Line(s): 165–166 vs 483–485
-
Issue: Nested identical deadlines. The outer scope starts first, so it expires slightly before the inner one. Cancellation then hits
finally: await stream.close(), which previously ran outside the inner cancel scope and could complete. Same-duration nesting is a fragile way to bound cleanup and can interrupt close. -
Suggestion: If the residual risk is a hanging
close(), wrap that call in its own shortmove_on_after(orreset()), rather than stacking twoQUERY_TIMEOUTscopes. -
File:
libp2p/kad_dht/peer_routing.py -
Line(s): 177–180
-
Issue: Timeout is logged at
debug, same as generic query failures. A silent peer is the DoS scenario the issue cares about;warningwould be easier to spot in production. -
Suggestion: Use
logger.warning(orinfo) for timeout, keepdebugfor expected empty results.
5. Security Review
The intent is correct: unbounded DHT RPC reads are a liveness/DoS issue (one silent peer stalling iterative lookup). That class of bug is already mitigated on main by the inner timeout.
- Risk: Residual hang if
stream.close()never returns after the inner timeout. NestedQUERY_TIMEOUTmay cancel close, but is not a dedicated cleanup bound. - Impact: low
- Mitigation: Timeout
stream.close()separately; considerstream.reset()on query timeout so the muxer does not wait for a graceful close.
No new subprocess, file, key-handling, or logging of secrets. Payload path still uses read_varint_prefixed_bytes_limited and signed-record checks.
6. Documentation and Examples
_query_peer_for_closestalready documents a per-operation timeout; the new comments on_query_single_peer_for_closestcontradict that docstring.- No public API change; no README / tutorial / example updates required.
- Local Sphinx HTML + doctest (
make linux-docs,-W) succeeded with no warnings.
7. Newsfragment Requirement
- Issue reference: Present (
Closes #1434). Mandatory issue requirement is met. - File:
newsfragments/1434.bugfix.rst— correct name,.bugfixtype, trailing newline. - Content: Format is valid; the claim is stale relative to
main. Reword or drop before merge so the changelog is truthful.
8. Tests and Validation
New test TestPeerRouting.test_query_single_peer_times_out_on_silent_peer PASSED locally. GitHub Actions for this PR are fully green.
Local make lint / make typecheck / make linux-docs: all passed.
Local make test: 3 failed, 3427 passed, 15 skipped, 4 warnings, 3 rerun. The three failures (test_put_and_get_value, test_provide_and_find_providers, test_reissue_when_listen_addrs_change) assert get_peer_record(...) is an Envelope but get None during dht_pair setup — a pre-existing signed-peer-record race, not caused by this diff. CI green supports treating them as local flakes.
9. Recommendations for Improvement
- Re-read
_query_peer_for_closeston currentmainand update #1434: the unboundedstream.readhang is already gone as of96151e12. - Prefer one timeout at the real I/O choke point (
_query_peer_for_closest), plus an explicit bound onstream.close()/stream.reset()infinallyif needed later. - Keep a silent-peer test; point it at the method that actually reads the stream.
- Reword or drop
1434.bugfix.rstso the next changelog does not claim this PR introduced the only timeout. - Log query timeouts at warning level (optional).
10. Questions for the Author
- Did you branch from a tree where
_query_peer_for_closeststill used unboundedstream.read, and miss96151e12after mergingmain? - Is the outer wrapper meant to cover
stream.close()hanging after the inner timeout? If so, why not timeout close directly?
11. Overall Assessment
- Quality Rating: Needs Work
- Security Impact: Low (intent is DoS liveness; inner timeout already provides the real bound)
- Merge Readiness: Needs fixes
- Confidence: High
Process checks (issue link, newsfragment filename, lint, types, docs, GitHub CI, clean merge) are fine. Please follow the Recommended path at the top before this can be approved.
… peer Review reshape (libp2p#1435): the unbounded-read hang from libp2p#1434 was already fixed on main by 96151e1 (move_on_after(QUERY_TIMEOUT) inside _query_peer_for_closest). Drop the redundant outer wrapper and its stale comments, point the silent-peer regression test at _query_peer_for_closest (the real I/O choke point), and reword the changelog entry so it credits the bound that is actually in place. Refs libp2p#1434
|
@acul71 Thanks — you're right, I'd branched before 96151e1 and the merges hid it. Reshaped as recommended in |
What
Reshaped per review: the unbounded-read hang from #1434 was already fixed on
mainby 96151e1 (move_on_after(QUERY_TIMEOUT)inside_query_peer_for_closest). This PR now only:_query_peer_for_closest(the real I/O choke point) — a peer that opens the stream and never replies yields[]afterQUERY_TIMEOUT;The redundant outer wrapper and its stale comments are gone (
peer_routing.pyis unchanged vsmain). #1434 closed as fixed by 96151e1.Refs #1434