Skip to content

fix(p2p): bound the inbound node-info exchange by the handshake deadline - #3916

Open
bdchatham wants to merge 3 commits into
mainfrom
fix/p2p-bound-inbound-nodeinfo
Open

fix(p2p): bound the inbound node-info exchange by the handshake deadline#3916
bdchatham wants to merge 3 commits into
mainfrom
fix/p2p-bound-inbound-nodeinfo

Conversation

@bdchatham

@bdchatham bdchatham commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Problem

Router.acceptPeersRoutine holds an accept-semaphore slot from before AcceptOrClose until after the peer's node info is exchanged. handshakeCtx carries handshake-timeout and covers handshake(), but the next line takes the connection context:

hConn, err := handshake(handshakeCtx, tcpConn, ...)   // bounded
info, err := exchangeNodeInfo(ctx, hConn, ...)        // unbounded
release()

exchangeNodeInfo blocks on conn.ReadSizedMsg, which has no deadline of its own and reads the length varint a byte at a time. The node info exchange is part of the handshake, so it should run under the same deadline; as written, a peer that goes quiet part way through holds its slot for as long as its socket survives.

What that guards is a peer losing connectivity mid-exchange, not a malicious one. A peer that completes the exchange releases the accept slot and moves into the inbound peer budget, where it can hold a connection open by answering pings while sending nothing useful, so this bound does not stop anyone determined.

Change

-  info, err := exchangeNodeInfo(ctx, hConn, *r.nodeInfoProducer())
+  info, err := exchangeNodeInfo(handshakeCtx, hConn, *r.nodeInfoProducer())

handshakeCtx already exists and already covers the handshake. This extends it over the rest of the region where the slot is held.

context.WithTimeout is an absolute deadline rather than an idle one, so a peer that drips bytes slowly is still cut off at handshake-timeout. Teardown was already correct: on cancellation tcp.Conn.Read calls CloseRead() and waits for the read pump to return data ownership, so the aborted read leaks neither a goroutine nor a half-open socket.

The default stays at 10s. Bounding the read is the fix; tuning the deadline down is a separate judgement about slow links.

Notes for review

  • No new test, and that is the gap I would most like a second opinion on. Pinning this needs a router built directly rather than through MakeTestNetwork: dial its endpoint with tcp.Dial, run handshake client-side to completion, send no node info, and assert the connection closes within the deadline. Happy to add it here if you would rather not merge without it.
  • No lock is held across the affected read. Advertise() and nodeInfoProducer() both complete before the I/O begins, and connTracker.AddConn's critical section is map operations only, so the slot is the only resource involved.
  • go build ./..., go test ./internal/p2p/, gofmt, goimports and golangci-lint (v2.8.0) are clean.

acceptPeersRoutine holds an accept-semaphore slot from before AcceptOrClose
until after the peer's node info is exchanged. handshakeCtx carries
handshake-timeout and covers handshake(), but exchangeNodeInfo on the next line
took the connection context instead, and it blocks on ReadSizedMsg with no
deadline of its own. A peer that completes the handshake and then stops
responding holds its slot for as long as it keeps the socket open, and the node
stops acquiring inbound peers while every health signal still reports green.

Run it under handshakeCtx, which already covers the handshake itself.

The default stays at 10s. Bounding the read is the fix here; tuning the deadline
is a separate judgement about slow links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bdchatham
bdchatham requested review from masih and pompon0 August 13, 2026 14:52
@cursor

cursor Bot commented Aug 13, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Narrow inbound accept-path context change aligned with existing outbound handshake timeout behavior; low risk to established peers that complete node-info promptly.

Overview
Inbound peers could keep an accept-semaphore slot after crypto handshake if they stalled during node-info exchange, because that step used the long-lived connection ctx while only handshake() ran under handshakeCtx.

acceptPeersRoutine now passes handshakeCtx into exchangeNodeInfo, so the same HandshakeTimeout caps the full pre-runConn handshake phase and the slot is released when the deadline fires.

Adds TestRouter_InboundNodeInfoBoundedByHandshakeDeadline: dial the router, finish handshake without sending node info, and expect the router to close the connection (EOF) within a short test timeout.

Reviewed by Cursor Bugbot for commit 4338572. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 13, 2026, 3:23 PM

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 58.47%. Comparing base (8bbac80) to head (4338572).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3916      +/-   ##
==========================================
- Coverage   59.48%   58.47%   -1.01%     
==========================================
  Files        2325     2229      -96     
  Lines      198647   188004   -10643     
==========================================
- Hits       118160   109936    -8224     
+ Misses      69258    67682    -1576     
+ Partials    11229    10386     -843     
Flag Coverage Δ
sei-chain-pr 77.85% <100.00%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-tendermint/internal/p2p/router.go 90.65% <100.00%> (ø)

... and 137 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One-line fix that bounds the inbound exchangeNodeInfo read by handshakeCtx, closing a slot-exhaustion vector where a peer could hold an accept-semaphore slot indefinitely; the change is correct and makes the accept path symmetric with the dial path, which already covers both handshake and node-info exchange under the same timeout. The only gap is the absence of a regression test, which the author already called out.

Findings: 0 blocking | 1 non-blocking | 0 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • No regression test covers the fix: a peer that completes the handshake and then stops sending node info should have its connection torn down and its accept-semaphore slot released once HandshakeTimeout expires. The existing router test sets HandshakeTimeout: utils.Some(time.Hour) (sei-tendermint/internal/p2p/router_test.go:283), so nothing exercises expiry and this one-line change could silently regress. The test sketch in the PR description (build the router directly, dial with tcp.Dial, run handshake client-side, send no node info, assert the connection closes within the deadline) looks like the right shape.

Builds a router with a short handshake-timeout, dials it, completes the
handshake so the peer is authenticated, then sends no node info. The router
must hang up rather than leave the accept slot held, which surfaces to the
dialer as EOF from the connection pump.

Verified it discriminates: with exchangeNodeInfo back on the connection
context the test hangs and go test's own timeout fires, which is the repo's
convention rather than an artificial timeout inside the test.

Two details worth knowing for anyone extending this. tcp.Conn.Run is
demand-driven, so a read has to stay outstanding for the pump to observe the
close at all. And a failed read blocks on ctx.Done() until the pump cancels
the scope, so the pump's error is the terminal signal rather than something to
swallow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bdchatham
bdchatham enabled auto-merge August 13, 2026 15:15
Comment thread sei-tendermint/internal/p2p/handshake_deadline_test.go Outdated
Comment thread sei-tendermint/internal/p2p/handshake_deadline_test.go Outdated
Comment thread sei-tendermint/internal/p2p/router.go Outdated
@bdchatham
bdchatham disabled auto-merge August 13, 2026 15:16
Greg: the node info exchange is part of the handshake, which is why it belongs
under the handshake deadline. What this guards is a peer losing connectivity
part way through, not a malicious one, since a malicious peer can complete the
exchange and hold the connection open with pings while sending nothing useful.
Comment rewritten to say that instead of framing it as an indefinite hold.

Masih: the read loop is bounded on ctx rather than looping forever, and the
final assertion uses require.ErrorIs.

Still discriminates: with exchangeNodeInfo back on the connection context the
test hangs and go test's timeout fires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bdchatham
bdchatham enabled auto-merge August 13, 2026 15:23
@bdchatham
bdchatham added this pull request to the merge queue Aug 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants