Skip to content

feat(app): wire the QUIC transport feature - #702

Draft
emlautarom1-agent[bot] wants to merge 10 commits into
mainfrom
worktree-shimmying-honking-moth
Draft

emlautarom1-agent[bot] wants to merge 10 commits into
mainfrom
worktree-shimmying-honking-moth

Conversation

@emlautarom1-agent

Copy link
Copy Markdown
Contributor

Closes #619.

Summary

run builds a NodeType::QUIC node when the quic feature is enabled, the one conditional Charon has in wireP2P. The node then binds the configured --p2p-udp-addresses alongside TCP, and QuicUpgradeBehaviour, installed on every node and gated on the node type, upgrades direct TCP connections to QUIC. quic stays Alpha, so run without --feature-set-enable=quic is unchanged.

Two smaller pieces ride along. The upgrade behaviour's decision logic gets deterministic unit tests driven against a seeded peer store. And Node derives what it advertises from the swarm's own listener table rather than the configured addresses, so a configured port of 0 never leaks into identify; relay circuit listeners are left out of that derivation, since their ports belong to the relay. The relay server gets its advertised addresses from that same path.

Differences from Charon

  1. QUIC gating is decided at construction. Charon's upgrade loop re-checks each tick whether the host has a QUIC listen or advertised address, so a QUIC node without --p2p-udp-address never attempts upgrades. Pluto gates on the node type, so such a node still dials peers' QUIC addresses, which works for outbound QUIC. Deliberate: simpler, and strictly more capable.
  2. TCP is closed after QUIC is up, not before. Charon closes direct TCP connections and rewrites the peerstore before dialing QUIC, opening a disconnect window. Pluto dials QUIC while TCP stays up and closes TCP only once the QUIC connection is established. Deliberate: no disconnect window, and rust-libp2p supports multiple connections per peer.
  3. Advertised addresses use bound ports throughout. Charon advertises bound internal addresses through its AddrsFactory but derives external IP / hostname addresses from the configured ports. Pluto derives both from the bound ports.

Out of scope

The compose harness defaults to --feature-set=alpha, so with this change every smoke node is a QUIC-type node without a UDP listener: one startup warning, QUIC transport installed, no upgrade attempted because no peer advertises a QUIC address. Charon nodes in the mixed scenarios behave the same. Giving run nodes a p2p-udp-address so the smoke tests exercise a real upgrade belongs in #689.

The server-path with_quic_enabled fix and QUIC server tests listed in #619 are on main via #694, and the upgrade behaviour's backoff tests via #666.

`run` builds a `NodeType::QUIC` node when the `quic` feature is enabled,
mirroring Charon's `wireP2P`. The node then listens on the configured
`--p2p-udp-address`es alongside TCP, and `QuicUpgradeBehaviour` upgrades
direct TCP connections to QUIC.

Drop the unused `is_quic_enabled` helper: the upgrade behaviour is gated
on the node type at construction.

Closes #619.
Drive `run_upgrade_logic` and the connection/dial callbacks directly
against a seeded `P2PContext`: a TCP-connected peer with known QUIC
addresses is dialed once and its TCP connection kept until QUIC is
established; peers without a direct TCP connection or a direct QUIC
address are skipped; a redundant TCP connection is closed once direct
QUIC exists; a TCP-only node never dials; and a non-QUIC connection or
dial failure during an upgrade records a failure and arms the backoff.
A configured port of 0 leaves the choice to the kernel, so the configured
address is not dialable. `Node` derives what it advertises from the
addresses libp2p reports as bound: the external IP / hostname on those
ports plus the bound addresses themselves, private ones withheld when
configured. The set is recomputed on `NewListenAddr`, `ExpiredListenAddr`
and `ListenerClosed`, applying only the difference so addresses registered
through `add_external_address` are left alone.

The relay server gets this from its startup poll, so it needs no explicit
re-advertise after `wait_for_listen_addrs`, and the p2p test fixture needs
no manual external address for its relay.
`Node` derives what it advertises from the swarm's own listener table,
skipping relay circuit listeners: their ports belong to the relay, so with
an external IP or hostname configured they would otherwise be advertised
as `/ip4/<external>/tcp/<relay-port>`, and identify lists them as listen
addresses regardless.

The external IP is parsed once when the config is applied, so deriving the
advertised set is infallible and the node stores only the two external
overrides it reads.
Keep the tests that exercise a decision compose and end-to-end runs cannot
surface: TCP stays up until QUIC is established and is not re-dialed
while armed, a relayed-only peer is left to force-direct, only the direct
TCP connection is closed once direct QUIC exists, and a failed upgrade
keeps TCP and arms the backoff. Drop the one-line-guard and helper-level
cases, and check the backoff state directly rather than through the
mutating `should_skip`.

@emlautarom1-agent emlautarom1-agent Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Inline notes for reviewers.

let p2p_context_for_handle = p2p_context.clone();

// Charon's `wireP2P` picks the node type off the same flag.
let node_type = if feature_set.enabled(pluto_featureset::Feature::Quic) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Differs from Charon. The node type decides both the transports installed and whether QuicUpgradeBehaviour acts, so a QUIC node with no --p2p-udp-address still dials peers' QUIC addresses (outbound QUIC works without a listener). Charon's upgrade loop instead re-checks every tick whether the host has a QUIC listen or advertised address and stays idle otherwise. Deliberate: the construction-time gate is simpler and strictly more capable, which is also why the unused address-based is_quic_enabled helper is gone.

Comment thread crates/p2p/src/p2p.rs
fn readvertise(&mut self) {
let listen_addrs: Vec<Multiaddr> = self
.swarm
.listeners()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Non-obvious. The listener table is the source of truth here because the swarm updates it before yielding NewListenAddr/ExpiredListenAddr/ListenerClosed, and Node::poll_next runs handle_event only after the swarm has yielded, so it is always current at this point. Circuit listeners are skipped because addr_port would pick up the relay's port and, with an external IP configured, advertise /ip4/<external>/tcp/<relay-port>.

`P2PConfig::multiaddrs` renders each socket address through the same
`with_transport` builder as the external addresses, so the two shapes
cannot drift and rendering cannot fail once the address has parsed.
One test follows a TCP-connected peer from the first tick to the closed
TCP connection; the failure cases share a setup and a check closure; and
the advertised-address test waits for a direct and a relayed listen
address rather than a fixed count.
Matches Charon's upgrade loop: with a direct QUIC connection in place,
every TCP connection to the peer is redundant, relay circuits included.
The relay manager re-routes a peer only once its last connection drops,
so the circuit returns if the direct connection fails.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wire the QUIC transport feature

0 participants