Repository navigation
CL transport: QUIC first (the spec's order), QUIC-only peers dialable - #731
Conversation
consensus-specs#5330 (merged 2026-07-06) made QUIC the transport a client MUST support and the one to dial first, TCP only a SHOULD fallback, and every other client ships it on by default now: Lighthouse since v4.5 and Grandine (rust-libp2p, our own stack), Prysm since v5.2.0, Lodestar since v1.42, Teku in 26.9 (its missing TCP fallback, #11397, fixed in October). Nimbus is the one client measured to stall over QUIC (v26.9.1, 2026-10-09). - discovery: a record with a QUIC port and no TCP one is a QUIC-only candidate, its QUIC address the peer's `addr`. - sync: `Peer::dial_addrs` returns QUIC first, TCP second; the pool follows a record that drops or gains TCP without losing the other transport. - reqresp: `TcpFirst`, a shared per-process set the client orders dials by. A peer goes there when its QUIC dial fails where TCP then reaches it (~30 % of QUIC-advertising mainnet peers forward no UDP port, Teku #11397's census), when a request times out on its QUIC connection (that connection is closed: requests ride any existing connection, so a stalled one would otherwise carry them all), and when it identifies as Nimbus (a QUIC connection it identified on is closed once). A request lost to such a close is retried once, TCP first. - Java engine (TCP-only): skips QUIC-only keys in the shared CL peer cache rather than dialing them and charging failures that would evict them. - docs: TODO entry (owed: re-measure Nimbus v26.10.1+, which vendors nim-lsquic 0.11.2), reimplementation spec, privacy table, status pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013jBQepLws2hUP2yijLdQjs
| // is refused, that connection is the only one there is and stays. | ||
| // Closing up front costs a reconnect; waiting costs the measured | ||
| // stall's first timeout (10 s, 60 s for updates_by_range). | ||
| if is_nimbus_agent(&agent) && prefer_tcp(&ctx.tcp_first, peer_id) { |
There was a problem hiding this comment.
Minor (hidden coupling): agent here is log_client_id(&info.agent_version) — the sanitized display form. That function's contract (el/pool.rs:553-570) says "Display text only — never a key, never compared", and where it concedes an exception it enumerates it explicitly ("The family prefix agent_serves_one_period matches on survives both the strip and the cap"). This adds a second comparing consumer without updating that note, so a future sanitizer change (different prefix handling, a tag prepended, …) would silently break Nimbus detection with no local hint.
Behavior today is correct — control-char stripping and the 64-char cap both preserve a leading nimbus — so this is doc/robustness only. Either match on the raw info.agent_version (in scope one line up), or add is_nimbus_agent to the enumerated prefix-matching consumers in log_client_id's doc and in the sanitization comment above (lines 1780-1784).
There was a problem hiding this comment.
Accepted: fixed in 56ad76d. The Identify handler now matches on the raw info.agent_version, not the display form from log_client_id, and a comment there explains why. That function's "never compared" contract holds again, so is_nimbus_agent no longer depends on how the sanitizer treats prefixes.
Generated by Claude Code
| // take the TCP address as the peer's identity (its | ||
| // cache key moves once, to the shape the Java engine | ||
| // can dial) so a TCP-first dial has TCP to go to. | ||
| p.addr = addr.clone(); |
There was a problem hiding this comment.
Minor (stale doc): add_with_quic's docstring (lines 1856-1860) still ends "Never a cache key, so no identity changes." — this hunk makes both halves false for the QUIC-only case: a QUIC-only peer's QUIC address is its cache key (it sits in addr, and cache_key() formats {addr}/p2p/{id}), and this line changes the identity exactly once when the record gains TCP. The adjacent test even pins the key move ("key moved to TCP once").
Cache-key stability is precisely what a reader consults that doc for, so it's worth rewording — e.g. "The QUIC side-address is never a cache key; the one identity change is a pooled QUIC-only peer whose record gains TCP (its key moves once, to the shape the Java engine can dial)."
There was a problem hiding this comment.
Accepted: fixed in 56ad76d. The add_with_quic doc now says the QUIC side address is never a cache key, and that there is exactly one identity change: a pooled QUIC-only peer whose record gains TCP moves its key once, from the QUIC address to the TCP one, a shape the Java engine can dial. It also covers the reverse case: a QUIC-only record for a peer pooled at TCP keeps the TCP key and learns the QUIC address as quic. Both cases are pinned in a_quic_only_peer_is_pooled_and_dialed_at_its_quic_address.
Generated by Claude Code
| * stores a QUIC-only peer under its {@code /udp/<port>/quic-v1} address; | ||
| * this engine's libp2p host is TCP-only, so such an entry is skipped here | ||
| * — left in the file for the Rust engine, never dialed and charged a | ||
| * failure that would evict it for both. |
There was a problem hiding this comment.
Observation (pre-existing, adjacent — follow-up material, not a blocker): this fixes the read side, but the Java engine's write side can still mint dead TCP keys for the same peers. The discv5 callback above (line 1067) caches Enr.toLibp2pMultiaddr(), which for a record with no tcp field falls back to the discv5 UDP port rendered as /tcp/<udp-port> (Enr.java:227-239, "CL convention: same port for both"). For a genuinely QUIC-only peer that fabricated address is dead: this engine dials it and charges it failures, and the Rust engine replays it too (as a distinct key beside its own /udp/<port>/quic-v1 entry, first-in-file wins the pool identity) — the same eviction noise this PR removes on the read path, just under a fabricated key.
The fallback predates this PR and was a reasonable heuristic while every client listened on TCP; now that QUIC-only records are an expected production shape (the premise of this PR), an ENR carrying a quic field but no tcp field is positive evidence the fallback is wrong for that record. Worth a follow-up: skip the UDP fallback when the record advertises QUIC without TCP.
There was a problem hiding this comment.
Accepted: fixed in this PR (56ad76d) rather than as a follow-up. I checked Enr.toLibp2pMultiaddr (core/.../enr/Enr.java): with no tcp field it falls back to udpAddress() and renders it as /tcp/<udp-port>, exactly as you describe. That fabricated key reaches both the discv5 callback (ChainStack line 1066, clPeerCache.add) and the DNS-tree seeding (buildAndStartBeacon). The fallback is now skipped when the record carries a quic field: such a record yields no multiaddr. A record with udp and neither tcp nor quic keeps the old same-port fallback. EnrTest.aQuicOnlyRecordYieldsNoFabricatedTcpAddress pins both. It uses hand-built records with ENR_STRING's key and a zero signature, since decode doesn't verify signatures. :core compiles locally, but its tests couldn't run in my sandbox (Maven Central returned 429), so CI has to confirm them.
Generated by Claude Code
|
Review: QUIC first, QUIC-only peers dialable Independent review of the full diff, held against the repo rules in CLAUDE.md and read in context of the surrounding code. No blocking defects found. Three minor inline comments posted (one stale docstring, one hidden coupling, one pre-existing adjacent gap worth a follow-up). Standing rules
Correctness points verified (all sound)
Bounded oddities — fine to ship, noting them for the record
Tests Verdict: ready once CI is green and the inline nits are dispositioned — nothing found that blocks. |
…d TCP for QUIC-only ENRs - reqresp: match Nimbus on the raw Identify agent, not the display form (`log_client_id` is never compared), so a sanitizer change cannot break it. - sync: `add_with_quic`'s doc states the one cache-key move (a pooled QUIC-only peer whose record gains TCP) instead of "no identity changes". - Java `Enr.toLibp2pMultiaddr`: a record with `quic` and no `tcp` is QUIC-only, so it yields no multiaddr rather than the udp-port-as-TCP fallback, which minted dead keys into the shared CL peer cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013jBQepLws2hUP2yijLdQjs
|
Reply to the review summary's "bounded oddities". The three inline comments are answered on their threads and fixed in 56ad76d.
Accepted as is, no change. (a) is intended: the request whose own timeout stalled the QUIC connection is exactly the one that should get a TCP attempt. (b) is real, but it is capped at one extra attempt per caller and needs a QUIC and a TCP connection to the same peer timing out in the same window. Narrowing it would mean tagging each failure with its connection's transport through
Accepted as is. It's true: past the cap, the close still happens but the peer isn't remembered. Reaching 4096 distinct demoted peers takes a pool of at most
Accepted as is. As you note, a client with TCP enabled advertises
Accepted as is. I checked
Deliberate. The retry needs the bytes again, and request payloads are tens of bytes (a Status, a by-range header, an empty finality request). Generated by Claude Code |
|
CI note: Build macOS .dmg (arm64) shows as cancelled on both heads of this PR (runs 42172514502 and 42174270991). That is not this PR's doing. The job is cancelled within a second of being created on Generated by Claude Code |
Why
#579 / #588 added QUIC to the Rust engine, but only as a fallback after TCP. The reason was one measurement against Nimbus v26.9.1. This PR revisits that against the other clients:
The spec changed too. consensus-specs#5330, merged 2026-07-06, makes QUIC a MUST and the primary transport, TCP a SHOULD fallback, and says to dial QUIC first.
One network-level fact shapes the design. Teku #11397's go-libp2p census found that about 30 % (183 and 177 of 600) of QUIC-advertising mainnet peers answered over TCP but timed out over QUIC: their UDP port isn't forwarded. So QUIC first needs a fallback that learns, not one that waits out the 5 s QUIC handshake on every redial.
What changes
discovery::filter_candidate). A record with aquic/quic6port and no TCP port becomes a candidate, with its QUIC address as the peer'saddr. IPv4 is still preferred over IPv6.sync::Peer::dial_addrs). The order is now QUIC, then TCP. The pool follows a record that gains or drops TCP without losing the other transport. A QUIC-only peer that later gains TCP takes the TCP address as its identity, so its cache key changes once to a form the Java engine can dial.reqresp::TcpFirst: a per-process set of peers that are dialed TCP first. It is shared by the client and the swarm and capped at 4096. A peer goes into it when:demote_stalled_quic), because requests ride any existing connection and a stalled one would otherwise carry all of them. This closes the old TODO "TCP-first is a dial order, not a connection choice";ChainStack.tcpDialable). Without this it would dial them and count failures that evict them from the file for both engines. The filter also covers the proven-bootstrap and served-range maps.Testing
cargo test -p myotis-net --lib: 833 passed.cargo clippy --workspace --all-targets -- -D clippy::incompatible_msrv(the rust-clippy job's command) is clean.cargo clippy -p myotis-net --all-targetsadds no new warnings in the changed code, and roost clippy with-D warningsis clean.TcpDialableClPeersTest) could not run locally: Maven Central returned HTTP 429 to this sandbox. CI will run it.Not verified / owed
reqresp::is_nimbus_agentif a QUIC connection holds across many streams./tcp/.https://claude.ai/code/session_013jBQepLws2hUP2yijLdQjs
Generated by Claude Code