p2p/sentry: share one p2p.Server across all eth protocols - #21335
Conversation
…cols Before: each requested eth protocol version backed its own p2p.Server bound to its own TCP/UDP port from --p2p.allowed-ports (30303/30304/30305 by default), all sharing the same node key. The discovery DHT keeps one ENR per Node ID (highest seq wins), so the per-Server ENRs raced at startup and only one survived — peers ended up dialing whichever port that one ENR advertised and inbound on the others stuck at zero. On a Hoodi node with maxpeers=80 and ProtocolVersion=[ETH69,ETH68] inbound on the eth/69 listener was 4-14 while the eth/68 listener got the attention; total peers plateaued near 30. With ETH69/70/71 on main the race is three-way. After: the node opens ONE TCP listener (and one UDP discovery socket) on --port (30303 by default) that carries every configured eth protocol version and the wit sideprotocol multiplexed per peer connection. One Node ID, one ENR, one port — no race. How it's wired: node/components/sentry.Provider builds the union of every GrpcServer's Protocols (dedup'd by name+version so wit isn't registered N times) and injects a single shared p2p.Server back into each GrpcServer via a new SetP2PServer hook. Side fixes that fall out of the share: - GrpcServer.Close() leaves an externally-injected Server alone so one sentry shutting down doesn't tear the listener out from under the others. - Peers() / NodeInfo() on non-reporter sentries return empty so admin_peers aggregation across sentries doesn't N-fold duplicate every entry. - NodeDatabase moves from per-protocol subdirs (nodes/eth68, nodes/eth69, …) to a single nodes/eth — existing per-protocol dirs become inert on upgrade and peer discovery rebuilds from bootnodes within a few minutes. User-visible: - One TCP port instead of three; firewall / monitoring setups that opened 30304/30305 specifically can drop those rules. - --maxpeers now caps total connections honestly (was effectively N×cap). - Standalone sentry binary and --sentry.api.addr (remote sentry) paths are unchanged; neither had the bug. Co-Authored-By: Claude
There was a problem hiding this comment.
Pull request overview
This PR changes Erigon’s local sentry wiring to run one shared p2p.Server (single ENR / Node ID / TCP listener) across all configured ETH protocol versions, instead of one p2p.Server per protocol/port. This addresses the discovery ENR “last writer wins” race when multiple Servers sign ENRs with the same Node ID but different ports.
Changes:
- Add a
GrpcServer.SetP2PServer(...)hook to inject an externally-managed, sharedp2p.Server, plus lifecycle/peer-reporting controls for shared-server mode. - Update the sentry Provider to build per-protocol
GrpcServerinstances, merge/dedupe theirp2p.Protocols, start one sharedp2p.Server, and inject it into all GrpcServers. - Unify node discovery DB layout from per-protocol subdirs to a single
nodes/ethdirectory in local multi-protocol mode.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| p2p/sentry/sentry_grpc_server.go | Adds shared-server injection (SetP2PServer), external ownership behavior in Close, peer-reporting gating for Peers/NodeInfo, and exports DNS discovery setup. |
| node/components/sentry/provider.go | Builds a shared p2p.Server across protocol GrpcServers, merges/dedupes protocol registrations, and centralizes node DB + listen-port selection. |
Comments suppressed due to low confidence (1)
node/components/sentry/provider.go:405
- GrpcServer.Close intentionally does not Stop() externally-injected p2p.Servers. Since the Provider is the coordinator creating this shared server, it should retain a reference and Stop() it in Provider.Close; otherwise Close can leave the TCP listener and P2P goroutines running unless the caller also cancels SentryCtx.
for i, ss := range p.Servers {
// First sentry (highest configured protocol version) reports peers.
ss.SetP2PServer(srv, i == 0)
}
return nil
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…iring Follow-ups to the shared p2p.Server refactor in the previous commit, addressing Copilot review feedback on PR #21335. - Provider.Close now stops the shared p2p.Server. GrpcServer.Close is a no-op for externally-injected Servers (by design — the coordinator owns lifecycle), so without this the listener + discovery goroutines outlive Provider.Close until SentryCtx is cancelled. - Protocol.Run waits up to 10s for the first SetStatus instead of disconnecting immediately when ss.statusData is still nil. The shared Server's listener comes up in Initialize; the multi-client's first SetStatus arrives later via StartStreamLoops in Provider.Start. A new GrpcServer.statusReady channel is closed on the first SetStatus, and awaitStatus selects on it / the timeout / ctx.Done. Closes the startup window where peers would have bounced off DiscProtocolError. - buildSharedP2PConfig now errors out when every entry in AllowedPorts is busy. The legacy per-protocol loop had this guard; my rewrite dropped it and would silently keep the last listenPort and fail on bind. - buildSharedP2PConfig probes port availability against 127.0.0.1 instead of an empty host. checkPortIsFree dials its target, and the common ":30303" form would yield "missing host" -> dial fails -> port reported free even when actually in use. The bug pre-existed in the legacy per-protocol loop too; cleaning it up here while the surrounding code is in motion. The ListenAddr returned to the Server keeps the original empty host so the listener still binds on all interfaces. Co-Authored-By: Claude
Adds focused unit tests covering the new shared-Server wiring that landed in
this PR, addressing the two test-coverage items from the Copilot review:
p2p/sentry:
- SetP2PServer flips external/reportsPeers correctly and panics on a
second injection (ownership is decided up front).
- Peers() / NodeInfo() on a non-reporter sentry return empty replies so
admin_peers aggregation across sentries doesn't N-fold every entry.
- Close() leaves an externally-injected Server running; Close() on a
self-owned Server stops it.
- awaitStatus returns immediately when statusData is already set, times
out cleanly when it isn't, and unblocks as soon as SetStatus stores
the first non-nil status — the startup-window fix in Protocol.Run.
node/components/sentry:
- buildSharedP2PConfig points NodeDatabase at nodes/eth, honours an
explicit ListenAddr when AllowedPorts is empty, picks the first free
AllowedPorts entry, errors when all are busy, and probes 127.0.0.1
when the configured host is empty (":port" form).
- startSharedP2PServer dedupes Protocols by name+version, wires the
shared p2p.Server into every GrpcServer, and marks only the first as
the peer-list reporter.
- Provider.Close stops the shared p2p.Server it created (the GrpcServer
side leaves it alone by design).
Also exposes GrpcServer.IsPeerReporter for observability — the dedup test
in the components/sentry package needs to inspect which sentry got marked
as the reporter, and it lives in a different package than the unexported
field.
Co-Authored-By: Claude
yperbasis
left a comment
There was a problem hiding this comment.
Overview
Fixes a real bug where erigon's local-mode sentry opened one p2p.Server per eth protocol version. Each Server signed its own ENR under the same Node ID but with a different listener port, racing in the discovery DHT — only the highest-seq ENR survives, so peers dial the "wrong" port and inbound peer count stalls at a fraction of --maxpeers.
The fix moves to geth's model: one p2p.Server with the union of all protocol caps multiplexed per peer connection. Side-effect cleanups (admin_peers de-duplication, MaxPeers honesty, externally-owned-Server lifecycle) fall out naturally.
Stats: +642 / −50, 4 files. Scope is well-contained to the sentry component.
What's strong about this PR
- Real bug with measurable impact. The PR body documents the discovery DHT race precisely, with concrete millisecond-level ENR seq timing and a Hoodi repro. Not a speculative refactor.
- Architectural alignment. Matches geth's design (one
Server, multiplexed protocols). Reduces port surface and removes a class of races. - Clean separation.
buildSharedP2PConfig(pure) andstartSharedP2PServer(lifecycle) are each small and testable; tests inprovider_test.goexercise both directly.SetP2PServeronGrpcServeris a minimal, well-documented injection hook. - Backwards-compatible. Standalone
cmd/sentryand remote--sentry.api.addrmode are untouched — they continue to use the lazy-server path insideSetStatus. The newstatusReadychannel doesn't change behavior for those (the constructor still creates it;awaitStatusreturns immediately if status is already set). - Good test coverage. Eleven new tests across two packages, including: dedup, reporter wiring, idempotent
SetP2PServerpanic, external-server lifecycle, owned-server lifecycle,awaitStatusfast/timeout/unblock paths, empty-host port probing, all-ports-busy error path. All pass undergo test -race.
I ran the full ./node/components/sentry/... and ./p2p/sentry/... test packages locally with -race -count=1 — all pass cleanly.
Concerns / suggestions
1. Doc/code mismatch: which sentry reports peers (p.Servers[0])
The PR description and the startSharedP2PServer doc comment both claim:
"Exactly one GrpcServer is marked as the peer-list reporter (the first one in p.Servers, which matches the highest entry in ProtocolVersion)"
But nodecfg/defaults.go:51 has:
ProtocolVersion: []uint{direct.ETH69, direct.ETH70, direct.ETH71},— lowest first, highest last. So p.Servers[0] is ETH69, not the highest. The new test in provider_test.go puts eth/71 as first, which is the opposite of the production default ordering.
Functionally this doesn't matter — p2pServer.PeersInfo() returns the global list regardless of which sentry calls it. But the doc comment will mislead a reader. Either fix the comment ("the first configured protocol version, typically the lowest") or, if "highest" is intentional, sort p.Servers by Protocols[0].Version desc before the i == 0 reporter pick. The latter is cleaner if you care about which goodPeers map the wit/0 traffic lands in (see #4 below).
2. Unlocked reads of external / reportsPeers in Peers() / NodeInfo()
p2p/sentry/sentry_grpc_server.go Peers() and NodeInfo() read these fields without holding p2pServerLock:
p2pServer := ss.getP2PServer() // takes RLock, releases it
if p2pServer == nil { ... }
if ss.external && !ss.reportsPeers { // unlocked read
return &sentryproto.PeersReply{}, nil
}SetP2PServer writes both fields under Lock. In practice this is fine because SetP2PServer is called once during Initialize, strictly before any gRPC handler can fire — there's a happens-before edge through the multi-client wiring. go test -race didn't flag it because the tests don't drive that race.
But it's a latent risk for future changes (e.g., a hot-reconfig path). Cheap fix: capture both fields inside getP2PServer (rename to return a triple, or add a sibling getServerState() (*p2p.Server, bool, bool)).
3. SetP2PServer panics on double-call
if ss.p2pServer != nil {
panic("sentry.GrpcServer: SetP2PServer called when p2pServer is already set")
}The invariant ("ownership decided up front") is sound, but a panic in node startup is harsh. An error return would let the provider fail Initialize cleanly with the same diagnostic. Not a blocker — the panic message is descriptive — but worth reconsidering.
4. wit/0 dedup picks the first server's handler
In startSharedP2PServer, the dedup keeps the first wit/0 Protocol encountered (currently from p.Servers[0], i.e. the ETH69 sentry by default ordering). So every wit-protocol peer's runWitPeer runs on the ETH69 sentry's goodPeers map — even peers that negotiate eth/71 with the ETH71 sentry. That peer ends up in p.Servers[0].goodPeers with protocol=0, witProtocol=0 AND in p.Servers[2].goodPeers with protocol=71.
Effect on runPeerCountLogger: such peers get counted under protocol=0 in the ETH69 sentry's bucket and under their actual eth version in the highest sentry's bucket — double-counted across protocols in the log line. admin_peers is unaffected (gated to the reporter, uses PeersInfo()). Cosmetic logging quirk, not a correctness issue. Worth a one-line note in the comment, or — more cleanly — bind the deduped wit Protocol to the reporter sentry so wit state co-locates with the global peer view.
5. awaitStatus: 10s timeout is reasonable but undiscoverable
awaitStatusTimeout = 10 * time.Second is a package-private constant. It governs how long an inbound peer's Protocol.Run waits for the multi-client to broadcast its first SetStatus. The window between the listener coming up (in Initialize) and the multi-client calling SetStatus (after BuildMultiClient + Start) is normally sub-second, so 10s is plenty.
But if anything blocks the startup path (slow chain init, large snapshot reindex, debugger), peers connecting in the window get a silent DiscProtocolError. Worth a debug-level log on timeout so the operator can correlate "no inbound for the first N seconds" with the cause. The current code drops to nil silently and the caller logs PeerErrorLocalStatusNeeded — which doesn't make the timeout vs. the actual "core didn't send status" case distinguishable.
6. BootstrapNodes mutation lives in the wrong layer
startSharedP2PServer does:
if cfg.BootstrapNodes == nil && len(chainBootnodes) > 0 {
bootstrapNodes, err := enode.ParseNodesFromURLs(chainBootnodes)
...
cfg.BootstrapNodes = bootstrapNodes
cfg.BootstrapNodesV5 = bootstrapNodes
}This duplicates the exact logic in p2p/sentry/sentry_grpc_server.go makeP2PServer. Since you're already building the p2p.Server yourself in startSharedP2PServer, you can either:
- Reuse
makeP2PServer(export it, or extract abuildBootstrapNodeshelper), or - Pull the DNS-discovery / bootnodes resolution into a single shared helper that both
makeP2PServerandstartSharedP2PServercall.
The current duplication is a future-divergence hazard.
7. Minor: awaitStatus doesn't return nil on ctx.Done() deterministically
select {
case <-ss.statusReady:
case <-time.After(maxWait):
case <-ss.ctx.Done():
}
return ss.GetStatus()On ctx.Done(), the function returns whatever GetStatus() is — which could be non-nil if SetStatus ran concurrently and ctx.Done() won the select. Probably fine (we just return the latest status either way), but the doc comment says "returns nil on timeout so the caller can disconnect the peer" without mentioning the ctx.Done case. Tighten the comment or explicitly return nil when ctx is done.
8. Test gap: end-to-end Initialize with the new shared-Server path
The new unit tests cover buildSharedP2PConfig and startSharedP2PServer in isolation, but no test exercises Provider.Initialize end-to-end in local mode (which is what the bug actually lived in). I understand that requires a full ChainDB + chainspec stub which is non-trivial, but it would be the strongest regression guard for the original DHT-race scenario. At minimum, a test that asserts after Initialize with len(ProtocolVersion) >= 2:
p.sharedP2PServer != nillen(p.Servers) == len(ProtocolVersion)p.Servers[0].GetP2PServer() == p.Servers[1].GetP2PServer() == p.sharedP2PServerp.Servers[0].IsPeerReporter() == true && p.Servers[1].IsPeerReporter() == false
would catch any future regression that breaks the wiring without a full network stand-up.
9. Backport plan
The PR notes that release/3.4 will need a separate cherry-pick PR landing the fix directly in node/eth/backend.go (no sentryProvider abstraction there). Given this is a peer-connectivity bug affecting users on 3.4 right now, that backport should be tracked as a follow-up immediately after merge. Per CLAUDE.md conventions, the title prefix is [r3.4].
Security / correctness
Nothing concerning. The new code:
- Doesn't introduce new network endpoints — actually closes off two (
--port+1,--port+2). - Doesn't change cryptographic material (same Node ID, same ENR signing).
- Doesn't loosen any peer admission checks.
- Bounds the new
awaitStatuswindow so a stuck startup can't cause unbounded blocking.
Performance
- One less
p2p.Serverper protocol version: less goroutine overhead, one discovery instance instead of N. - One DNS-discovery iterator per eth/N protocol still (created in each
NewGrpcServer) — same DNS list. Could be deduped to one DNS iterator shared across protocols, but it's not visibly costly and matches geth's per-cap-iterator pattern. Out of scope for this PR.
Verdict
Solid PR. The core change is correct, well-tested, and addresses a documented production bug. My concerns are all polish (doc accuracy, future-proofing locking, factoring duplicated logic). Recommend:
- Fix the "highest entry in ProtocolVersion" comment in
provider.goand the PR body — it's the first configured entry (which is the lowest in the default). - Add a one-line debug log when
awaitStatustimes out. - (Optional but nice) Capture
external/reportsPeersunderp2pServerLockinPeers/NodeInfofor hygiene. - Track the
[r3.4]backport as a follow-up.
Items 5-8 above are nice-to-haves that can land later. Nothing in this PR blocks merge.
Findings from Copilot (3) and yperbasis (8). The Copilot findings on Peers routing, empty NodeInfo, and close(statusReady) are real; yperbasis identified the matching doc/code mismatches and a few polish items. 1. Peers() / message routing (Copilot #1). The multi-sentry client uses Peers() to decide which sentry owns each peer and routes SendMessageById via that sentry's gRPC. With the previous reporter-only gating, every peer mapped to Servers[0] (which is the *lowest* configured protocol, ETH69 in the default — yperbasis #1 noted the comment said "highest"). Servers[0]'s goodPeers doesn't have eth/70 or eth/71 peers, so SendMessageById silently no-op'd. Fix: each GrpcServer.Peers() now reports its own goodPeers, filtered to skip entries where both protocol and witProtocol are zero. Each peer ends up in exactly one eth-sentry's goodPeers (the negotiated version) plus, at most, the sentry hosting the wit sideprotocol, so admin_peers aggregation is naturally non-duplicating and routing is correct. 2. NodeInfo() (Copilot #3). Non-reporters returning empty replies polluted admin_nodeInfo with blank entries that sorted first. With the shared p2p.Server every sentry has the same Node ID and the same enode, so they now return identical NodeInfo. node/eth.NodesInfo deduplicates by Enode before sorting. 3. SetStatus's close(ss.statusReady) (Copilot #2) panicked for callers that construct GrpcServer outside NewGrpcServer (existing TestSentryServerImpl_* does this). Guarded the close with a nil check; awaitStatus tolerates a nil channel via the select's other cases. 4. SetP2PServer returns an error instead of panicking on double-call (yperbasis #3). The "ownership decided up front" invariant is still enforced, just propagated up the Provider.Initialize path cleanly. 5. SimplePeerCount filters protocol=0 ghosts (yperbasis #4). With wit/0 deduped to one sentry, peers that negotiate eth/N on a different sentry end up as protocol=0/witProtocol=0 ghosts on the wit-hosting sentry. Counting them would emit a bogus eth.ProtocolToString[0] bucket in the GoodPeers log. The Peers() filter already drops them from admin_peers; this aligns SimplePeerCount. 6. awaitStatus logs a Debug line when its timeout fires (yperbasis #5) so operators can tell "core didn't send status in time" from "core never tried" when the caller disconnects the peer with PeerErrorLocalStatusNeeded. Doc comment clarifies the ctx.Done case too (yperbasis #7). Drops the reportsPeers flag and IsPeerReporter accessor — no longer needed once per-sentry goodPeers replaces the reporter gating. SetP2PServer signature loses its second argument. Tests updated for the new shape; new TestGrpcServer_PeersReturnsPerSentryGoodPeers (per-sentry view + ghost-entry filter) and TestGrpcServer_SetStatus_NilStatusReadyIsSafe (close-nil guard). Deferred to follow-up: - BootstrapNodes/DNS resolution helper to dedupe logic between makeP2PServer and startSharedP2PServer (yperbasis #6). - End-to-end Provider.Initialize test in local mode (yperbasis #8). - [r3.4] backport PR (yperbasis #9). Co-Authored-By: Claude
Three new findings, all real lifecycle / probing edge cases. 1. SetP2PServer accepted a nil *p2p.Server and still flipped external=true. A subsequent SetStatus would then take the lazy path and build its own Server, but Close() would see external=true and skip Stop() — leaking the listener and discovery goroutines. Reject nil at the door. 2. startSharedP2PServer leaked the shared p2p.Server on inject failure: srv.Start succeeds and p.sharedP2PServer is assigned, but if any SetP2PServer call later errors, the partial Server was left running when Initialize returned. Stop it and clear the field on that path. 3. checkPortIsFree only handled the empty-host form of an unspecified bind address. 0.0.0.0:N and [::]:N would also DialTimeout-fail and read as "port is free" even when a listener was actually bound on all interfaces. Add a loopbackProbeHost helper that maps all four unspecified forms (empty, 0.0.0.0, ::, [::]) to a concrete loopback target so the probe actually exercises the listener; ListenAddr itself keeps the original host. Tests added: - TestGrpcServer_SetP2PServer_RejectsNil — nil rejection + external flag invariant. - TestLoopbackProbeHost — table-driven coverage of the four unspecified forms and concrete-host passthrough. Co-Authored-By: Claude
Copilot review on PR #21335: with dedupe-by-Enode in place, the previous for-i loop over the first `limit` sentries could return fewer than `limit` unique entries if duplicates appeared early. Treat `limit` as the cap on *unique* results and keep iterating the rest of the sentry list past duplicates so a hybrid layout (some sentries sharing a p2p.Server, others external) can still fill the cap. Co-Authored-By: Claude
ss.peers becomes atomic.Pointer[PeerStore] so SetSharedPeerStore is race-free under the documented "swap before srv.Start" contract — a bare field write would trip the race detector if anyone ever reshuffled the call ordering. Readers Load() once and use the inner mutex as before. Cache the eth protocol version in GrpcServer.ethVersion (set in NewGrpcServer) so Peers / SimplePeerCount don't rescan ss.Protocols on every call; the ethProtocolVersion() helper goes away. Both filters now combine the cached version with #88bae21e's locked EthProtocol() snapshot. writePeer's nil-rw drop branch now emits a Trace log carrying the (protocol, msgID, peerID) so an operator chasing a missing message has a fingerprint to grep for instead of guessing. startSharedP2PServer validates that every GrpcServer has a nil p2pServer before mutating any of them — defends against a future change that makes SetP2PServer fail mid-loop and leaves half the sentries holding the shared PeerStore. Verified with `go test -race ./p2p/sentry/ ./node/components/sentry/` and `make lint`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rs to 64
Follow-up to the single-shared-Server change. With one p2p.Server carrying
all eth versions on --port, --p2p.allowed-ports (which selected one TCP port
per protocol from a list) no longer means anything. Removing it:
- Drops the P2pProtocolAllowedPorts CLI flag and its registration in
node/cli/default_flags.go.
- Drops the allowedPorts parameter from utils.NewP2PConfig and the
flag wiring in cmd/sentry/main.go.
- Drops the AllowedPorts field from p2p.Config.
- Replaces the AllowedPorts fallback loop in buildSharedP2PConfig with
a straight ListenAddr passthrough; if --port is busy the bind now
fails fast (matches geth).
- Removes the obsolete port_util.go helpers (checkPortIsFree,
loopbackProbeHost, splitAddrIntoHostAndPort) and the four
AllowedPorts/probe-host tests in provider_test.go.
- Strips AllowedPorts from three test helpers (polygon miner, engine
api tester, txpool p2p client) — they relied on []uint{0} as an
ephemeral hint, but ListenAddr already does that.
Also bumps default MaxPeers 32 -> 64. Pre-fix with 3 eth protocol Servers
the effective cap was ~3*32 = 96; geth defaults to 50. 64 lands above geth
with a small headroom so existing operators don't see a sharp drop after
upgrading.
Also fixes the stale per-protocol qualifiers in flag Usage strings
(MaxPeers, MaxPendingPeers) and the NodesDir doc comment that still
referenced eth68/eth69 subdirectories.
Docs:
- README.md port table collapses sentry 30303/30304 -> 30303.
- docs/site/docs/fundamentals/default-ports.md table + Sentry CLI
section updated; --p2p.allowed-ports bullet removed.
- docs/site/docs/fundamentals/multiple-instances.md port-allocation
table updated.
- ChangeLog.md gets a [3.5.0] Breaking Changes entry with a before/
after table and migration bullets.
Verified with `go test -race ./p2p/sentry/ ./node/components/sentry/`,
`make lint`, and `make erigon integration`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The flag is gone (see prior commit) — the three skill files that documented or scripted it (erigon-network-ports, erigon-ephemeral, launch-devnet) would otherwise tell agents to pass a flag that errors at startup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The docs-site CI step `python3 docs/site/scripts/generate-llms.py --check` caught that the committed llms-full.txt artifacts drifted from regenerated content after the default-ports.md / multiple-instances.md edits in the prior commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot review (#21335 r3288296443): SetStatus is a gRPC entrypoint exposed over the wire by cmd/sentry, but it dereferences statusData.ForkData unconditionally (genesisHash on the entry, and HeightForks/TimeForks for the ENR entry builder). A malformed payload with nil ForkData would crash the sentry via nil-pointer panic. Guard at the door: return a clear error if either statusData or statusData.ForkData is nil. Test covers both shapes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s, findPeerByMinBlock, PeerEvents (erigontech#21394) Follow-up fixes for erigontech#21335 (merged). ## Bugs fixed With the shared `PeerStore` introduced in erigontech#21335, every `GrpcServer` can see **all** peers regardless of which eth protocol version they negotiated. Three `rangePeers` loops were missing the per-sentry version filter that `Peers()` and `SimplePeerCount()` already apply correctly: ### 1. `findBestPeersWithPermit` / `findPeerByMinBlock` Used by `SendMessageByMinBlock` to select target peers. Without the filter, an eth/71 sentry could pick an eth/69 peer: - `messageCode` encodes the request with **eth/71** wire codes. - `writePeer` sends it over the eth/69 peer's `ethRw`. - The remote eth/69 peer receives an unrecognised (or misinterpreted) code and disconnects. Contrast: `SendMessageToRandomPeers` and `SendMessageToAll` already filter with `protocolVersions.Contains(peerInfo.EthProtocol())`. ### 2. `PeerEvents` replay The replay pass at the start of `PeerEvents` iterated the entire shared store and emitted a `Connect` event for every peer. Subscribers for eth/68 would receive `Connect` events for eth/69 and eth/71 peers. Those peers never generate a `Disconnect` on the eth/68 sentry's `peersStreams`, so the subscriber accumulates ghost peers that are never cleaned up. ## Fix Apply the same two-condition guard already used by `Peers()` and `SimplePeerCount()`: ```go if pv := peerInfo.EthProtocol(); pv == 0 || pv != ss.ethVersion { return true } ``` ## Tests Three new tests added to `sentry_grpc_server_test.go`: - `TestGrpcServer_FindBestPeersWithPermit_FiltersVersion` - `TestGrpcServer_FindPeerByMinBlock_FiltersVersion` - `TestGrpcServer_PeerEvents_ReplayFiltersByVersion` All existing tests pass. `make lint` clean ×2.
https://github.com/erigontech/erigon/releases/tag/v3.5.0 https://github.com/erigontech/erigon/releases/tag/v3.5.1 https://github.com/erigontech/erigon/releases/tag/v3.5.2 The --p2p.allowed-ports flag was removed in: erigontech/erigon#21335 Signed-off-by: Jakub Sokołowski <jakub@status.im>
The 3.5 series removed several flag families that the docs never absorbed, so release/3.5 (the deployed branch) still tells users to pass flags that no longer parse. * --p2p.allowed-ports (8 occurrences / 4 files). Removed by #21335 "p2p/sentry: share one p2p.Server across all eth protocols" (in 3.5, not 3.4): all eth protocol versions now share a single p2p.Server, so one listening port set via --port is sufficient. The migrating-from-geth examples are updated accordingly. This also fixes a latent casing bug in that page, which wrote --P2P.allowed-ports; Go flags are case-sensitive. * --clique.checkpoint / .snapshots / .signatures / .datadir. Clique is gone from the tree entirely — no clique package remains. * --diagnostics.endpoint.port and the --diagnostics.* lines in the downloader / sentry / txpool / rpc-daemon --help blocks. Removed by #21351 "cmd/diag, go.mod: remove diag CLI". The "Diagnostics" section of default-ports.md documented only this flag and is dropped with it. * --polygon.sync in the rpc-daemon --help block. Removed by #16035 "Remove unused polygon sync flags"; the polygon flags that remain are polygon.pos.ssf, polygon.pos.ssf.block and polygon.wit-protocol. * --rpc.maxgetproofrewindblockcount.limit in the same block. MaxGetProofRewindBlockCount survives as an internal config field (http_cfg.go, default 100_000) but has no CLI registration, so it is not settable and must not be documented as a flag. Verified per family: no Go string literal and no corresponding identifier on release/3.5, release/3.6 or main, and present through release/3.4 where applicable. Also in this change: * --beacon.api.ide.timeout -> --beacon.api.idle.timeout in caplin.md. The table carried the note "flag name is `ide` not `idle` — typo in source". That was accurate for v3.4, where flags.go really did define "beacon.api.ide.timeout", but #20289 "cmd: fix typo in flag beacon.api.ide.timeout" corrected the source for 3.5 and the note was left behind. Every live branch now defines only beacon.api.idle.timeout, so the flag name is corrected and the note removed. Default (25s) is unchanged and matches Value: 25. * Fix a 404: the Ethereum on ARM GitHub link pointed at diglos/ethereum-on-arm; the project is at diglos/ethereumonarm. * Type two bare code fences (architecture.md, database.md) per the docs style rule, so Prism highlights them. The --help paste blocks had only their obsolete lines removed rather than being regenerated wholesale; regenerating them from freshly built downloader/sentry/txpool/rpcdaemon binaries is worth doing as a follow-up. Pre-push gate: npm ci && npm run build green (onBrokenLinks/onBrokenAnchors throw), generate-llms.py --check OK (74 pages), render-disk-sizes.py --check OK, editorial-artifact scan clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to review of this PR (Copilot + two adversarial passes). Wrong information corrected: * caplin.md documented the three Beacon API timeouts as `5s` / `31536000s` / `25s`, but all three are cli.Uint64Flag taking a bare integer number of seconds (Value: 5 / 31536000 / 25). `--beacon.api.idle.timeout=25s` does not parse. Values are now plain integers with "in seconds" in the description. * The Ethereum on ARM repository was transferred: diglos/ethereumonarm now 301-redirects to EOA-Blockchain-Labs/ethereumonarm. Point at the canonical URL rather than relying on the redirect. * default-ports.md still described the pre-#21335 two-listener model — separate `30303` (eth/68) and `30304` (eth/69) Sentry rows, plus prose saying both are typically exposed. Since #21335 there is a single listener on `:30303` serving ProtocolVersion [ETH69, ETH70, ETH71] (nodecfg/defaults.go), and eth/68 is not in the defaults at all. Same stale pair fixed in the default port allocation table of multiple-instances.md. * configuring-erigon.mdx gave `--p2p.protocol` defaults as `68, 69`; the actual default is the full ProtocolVersion slice `69, 70, 71`. * sentry.mdx --help block had two wrong defaults: `--maxpeers` is 64, not 32 (nodecfg/defaults.go MaxPeers), and `--p2p.protocol` is [69,70,71], not 68. * migrating-from-geth.mdx had an unmatched closing parenthesis (Copilot). Note on the diagnostics attribution in the previous commit message: those four flags were NOT removed by #21351, which only deleted the cmd/diag CLI. They last exist on release/3.3 (diagnostics/setup.go) and are already absent on release/3.4, so those docs had been stale since the 3.4 series rather than the 3.5 one. The deletions themselves are unaffected. Pre-push gate re-run: npm ci && npm run build green, generate-llms.py --check OK (74 pages), render-disk-sizes.py --check OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… flag, disk sizes (erigontech#22799) The `main` side of this week's documentation maintenance (w31). Carries the same stale-flag cleanup as erigontech#22793 (`release/3.5`) and erigontech#22798 (`release/3.6`) under the dual-commit rule, plus three findings that apply only to `main`. ## 1. Flags removed from the code but still documented Identical to erigontech#22793 — see that PR for the per-family provenance: * `--p2p.allowed-ports` — 8 occurrences / 4 files, removed by erigontech#21335 *"share one p2p.Server across all eth protocols"*; a single `--port` now suffices. Also fixes the `--P2P.allowed-ports` casing bug. * `--clique.checkpoint` / `.snapshots` / `.signatures` / `.datadir` — clique is gone from the tree. * `--diagnostics.*` — last present on `release/3.3`, already absent from `release/3.4`. * `--polygon.sync` — removed by erigontech#16035. * `--rpc.maxgetproofrewindblockcount.limit` — an internal config field with no CLI registration. ## 2. Wrong information corrected Also identical to erigontech#22793: Beacon API timeouts are bare integer seconds (`25`, not `25s` — they are `cli.Uint64Flag`, so `25s` does not parse); the pre-erigontech#21335 two-listener port model removed from `default-ports.md` and `multiple-instances.md`; `--p2p.protocol` default is `69, 70, 71`; `--maxpeers` default is 64; the Ethereum on ARM link now points at the canonical `EOA-Blockchain-Labs/ethereumonarm`; one unmatched parenthesis. ## 3. `--db.read.concurrency` semantics — `main` only erigontech#22408 *"node, commitment: fix parallel exec deadlock on many-core machines"* rewrote this flag's behaviour, and three pages said the opposite of what the code does. Each parallel-execution worker holds a long-lived read transaction, so a ceiling below the worker count would deadlock. The value is silently **raised** to the worker count, and lowering the flag does not reduce read concurrency at all — `--exec.workers` is the knob for that. The docs advised the reverse: *"Low values are fine for low read-concurrency nodes (for example, validators)"*. Verified against source rather than the commit message: the clamp is `cmd/utils/flags.go:2041` (`RoTxsLimit(c, cfg.ExecWorkerCount)`), and the existing *"HTTP/WebSocket fail fast with an overload response"* wording is **still accurate** (`rpc/http.go:241`, `httpOverloadedKey` / `kv.ErrReadTxLimitExceeded`), so it is kept rather than dropped along with the rest. Does not apply to `release/3.5` or `release/3.6`, which predate erigontech#22408. ## 4. `--witness.cache.blocks` — new today erigontech#22384 *"rpc/jsonrpc: eager in-memory cache for `debug_executionWitness`"* registered `utils.WitnessCacheBlocksFlag` earlier today, leaving the CLI reference one flag behind. It trades memory for `debug_executionWitness` latency, so it is operator-facing and in scope per the flag-coverage rule. Documented from the source `Value`/`Usage`: default `0` (disabled), clamped at `96`, embedded RPC only, requires commitment history. The source `Usage` names the `--prune.experimental.include-commitment-history` alias; the docs use the canonical `--prune.include-commitment-history`. ## 5. Disk sizes `disk-sizes.json` is brought in line with `release/3.5` (mainnet and gnosis, all three modes, measured 2026-07-19 / 2026-07-21) and the static markers in `hardware-requirements.mdx` re-rendered with `render-disk-sizes.py`, so the live branches carry one consistent set of numbers. ## Verification * `npm ci && npm run build` — green (`onBrokenLinks` and `onBrokenAnchors` both `throw`) * `generate-llms.py --check` — OK, 4 files, 74 pages * `render-disk-sizes.py --check` — OK * Step-4c bidirectional sweep — **233 registered, 10 undocumented**, i.e. the intentionally-undocumented internal/dev baseline, with no stale flags remaining * No `allowed-ports` / `diagnostics.` / `clique.` / `beacon.api.ide` / `polygon.sync` / `maxgetproofrewind` / `eth/68` references left in `docs/site/docs` --------- Co-authored-by: Bloxster <gianni.morselli@erigon.tech> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Alex Sharov <AskAlexSharov@gmail.com>
Summary
Erigon now opens a single TCP listener on
--port(30303 by default) carrying every configured eth protocol version, instead of one listener per protocol on 30303/30304/30305.This fixes a discovery-DHT race that left inbound peers stuck at a fraction of
--maxpeersfor multi-protocol deployments.The bug
Each entry in
ProtocolVersion(today:[ETH69, ETH70, ETH71]onmain) used to back its ownp2p.Serverbound to its own port from--p2p.allowed-ports. All those Servers share the same node key, so each one signed an ENR for the same Node ID but advertising its own port. The discv4 DHT keeps one ENR per Node ID — the one with the highestseq— so the Servers raced at startup and only one of the ENRs survived in the network's view.Concrete repro on a live Hoodi node with
maxpeers: 80andProtocolVersion=[ETH69, ETH68]:seqnumbers fromerigon_nodeInfoare within a few milliseconds of each other.MaxPeers/DialRatio), so the entire delta was on the inbound side.With three protocols (
ETH69/70/71onmain) the race is three-way and a node can spend the whole session advertising whichever port is least useful.The fix
node/components/sentry.Providernow:sentry.GrpcServerper protocol (so the existingMultiClient/ protocol-routing keeps working).Protocols(dedup'd byname+versionsowitisn't registered N times).p2p.Serverwith that union and starts it on a single port.SetP2PServer(srv)hook.Result: one Node ID, one ENR, one TCP listener, one UDP discovery — multiple protocols multiplexed per peer connection (which is how
p2p.Serverwas designed to work in the first place, and how geth has always done it).Side fixes that fall out of the share
GrpcServer.Close()leaves an externally-injected Server alone (external == trueskipsStop) so one sentry shutting down doesn't tear the listener out from under the others;Provider.Closeowns stopping the shared server.sentry.PeerStoreis wired into every GrpcServer so wit/0 (deduped to a single sentry) and the negotiated eth/* (on a different sentry per peer) see the samePeerInfoand share its eth-ready signal. The field usesatomic.Pointer[PeerStore]so the swap is race-free.pi.protocol/pi.witProtocolreads go through locked accessors (EthProtocol(),WitProtocol()); the writers (SetEthProtocol,SetWitProtocol) already holdpi.lock. Verified withgo test -race.pi.rwis split into per-subprotocolpi.ethRw/pi.witRwsowritePeercan route bysentryproto.MessageId(globally unique) — necessary because eth and wit reuse low msgcodes (e.g.0x01is botheth.NewBlockHashesMsgandwit.NewWitnessHashesMsg).Peers()/SimplePeerCount()filter by the GrpcServer's own eth version (cached inss.ethVersion), so the sharedPeerStoredoesn't N-fold-duplicateadmin_peersaggregation.node/eth.NodesInfodeduplicates by Enode — every sentry now returns identicalNodeInfo, so the dedup is what keepsadmin_nodeInfofrom listing the same node N times.NodeDatabasemoves from per-protocol subdirs (nodes/eth68,nodes/eth69, …) to a singlenodes/eth. Existing per-protocol dirs become inert on upgrade — peer discovery rebuilds from bootnodes within a few minutes.User-visible changes
--portis opened now, not--port,--port+1,--port+2. Firewall / monitoring / Kubernetes Service rules that explicitly opened 30304 and 30305 can drop those entries.--p2p.allowed-portsflag removed. Drop it from CLI args / config files; passing it now errors.--portis the only knob.--maxpeerscaps total connections honestly. Before this fix, with N protocols enabled, each per-protocol Server enforcedmaxpeersindependently, so the actual ceiling was ~N×maxpeers. The new ceiling matches what the flag's docs say.--maxpeersbumped 32 → 64 to compensate for the now-honest cap. (With 3 eth versions and the old multiplication, the effective ceiling was ~96; geth defaults to 50; 64 lands above geth with a small headroom.)sentrybinary (cmd/sentry) and--sentry.api.addr(remote sentry over gRPC) are functionally unchanged — neither had the bug. Thecmd/sentryflag surface shrinks alongside the main binary: it no longer accepts--p2p.allowed-ports.nodes/eth{68,69,…}/; nothing on disk is deleted, the dirs are just no longer read. Discovery refills from bootnodes within a few minutes.Why not the smaller fixes I considered
maxpeers.The shared-Server route is the structurally clean one and matches geth's model.
Verification
make erigon integrationbuilds clean.make lintclean (×3 — non-deterministic per CLAUDE.md).go test ./p2p/sentry/... ./node/components/sentry/... ./node/eth/...all green.go test -race ./p2p/sentry/ ./node/components/sentry/clean.in_30303=0,in_30304>0consistently after a restart that lost the eth/69 race. With this patch applied the race goes away because there's only one ENR.Test plan
--maxpeersis reached.--portshows up innetstat -ltnp | grep erigon(not--port+1/+2).admin_peersreturns each peer once (nolen(ProtocolVersion)duplication).admin_nodeInfoshows one enode (one port)../build/bin/sentry --datadir=… --chain=….--sentry.api.addr=…mode still connects to an external sentry process.🤖 Generated with Claude Code