Skip to content

feat: dial direct connections for DCUtR instead of reusing the relay - #274

Merged
seetadev merged 1 commit into
mainfrom
feat/258-dcutr-production-integration
Aug 24, 2026
Merged

feat: dial direct connections for DCUtR instead of reusing the relay#274
seetadev merged 1 commit into
mainfrom
feat/258-dcutr-production-integration

Conversation

@adust09

@adust09 adust09 commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Closes #258. Depends on #266, #267 (merged).

Problem

dial checked the connection pool before looking at the addresses given to it. DCUtR runs while a relayed connection to the same peer exists, so it got that connection back and reported success without emitting a packet. No TCP simultaneous connect is possible that way. Neither side dialled directly, and nothing started the upgrade at all.

specs/relay/DCUtR §5: "Upon receiving the Sync, A immediately dials the address to B. Upon expiry of the timer, B dials the address to A. This will result in a TCP Simultaneous Connect. For the purpose of all protocols run on top of this TCP connection, A is assumed to be the client and B the server."

Change

DialOpts and dialWith mirror the two context values go-libp2p's hole puncher sets together, WithForceDirectDial and WithSimultaneousConnect. dial is the wrapper supplying defaults, so no caller changed.

  • doForceDirect skips pool reuse and the backoff check (still records backoff on failure, as go does) and skips dial dedup. Skipping dedup is required: DCUtR dials once per address so all are attempted at the same instant, and swPendingDials shares one result among waiters, which would collapse them to a single address.
  • doUpgradeAsClient selects the security and muxer roles. upgradeOutbound/upgradeInbound collapse into upgradeAs, deriving every role from the direction as go's upgrader does (isServer := dir == DirInbound). Without it both ends pick Yamux RoleClient and their stream ids collide.

The reversed connection is Inbound and its resource slot is reserved and released as Inbound. closeConnection releases by connDirection, so the two must agree.

Testing surfaced a second defect: neither the hole punch dial nor the coordination exchange was bounded. A simultaneous connect that fails to collide lands on the peer's ordinary listener, leaving both ends running the responder side and waiting on each other forever. Both are bounded now, with go's defaults of 10s for the dial and 1m for the stream.

isPublicAddr is new in LibP2P.Multiaddr, mirroring go-multiaddr's manet.IsPublicAddr. Nothing equivalent existed. isRelayedAddr moves next to it so the connection pool can use it without depending on the NAT stack.

lookupConn now prefers direct over relayed, keeping the choice in one place as go's bestConnToPeer does, and still returns the relay when it is all there is. On success the relay closes after a 15s grace period, but only if a direct connection is still up.

registerNATHandlers subscribes a notifier on Inbound && isRelayedAddr, the same condition as go's holepuncher.go:281. upgradeRelayedConnection is exported for direct use; it calls identifyPeer itself rather than racing the identify notifier.

Out of scope

Role flip on the final retry, which is go's pre-v0.41 compatibility workaround and not in the spec; revisit under #131. QUIC hole punching (#235). Migrating existing long-lived streams, which the spec says "will have to be recreated".

dial checked the connection pool before looking at the addresses it was
given. DCUtR necessarily runs while a relayed connection to the same peer
exists, so both sides got that connection back and reported success
without emitting a single packet -- which cannot produce the TCP
simultaneous connect the spec relies on. Neither side of the exchange
dialled directly: registerDCUtRHandler used the plain dial, and nothing
ever started the upgrade in the first place.

specs/relay/DCUtR, step 5: "Upon receiving the Sync, A immediately dials
the address to B. Upon expiry of the timer, B dials the address to A.
This will result in a TCP Simultaneous Connect. For the purpose of all
protocols run on top of this TCP connection, A is assumed to be the
client and B the server."

Introduce DialOpts and dialWith, mirroring the two context values
go-libp2p's hole puncher sets together, network.WithForceDirectDial and
network.WithSimultaneousConnect:

- doForceDirect bypasses pool reuse and the backoff check, as go-libp2p
  does, while still recording backoff on failure. It also bypasses dial
  deduplication, which is required rather than incidental: DCUtR calls
  its dialer once per address so every address is attempted at the same
  moment, and swPendingDials shares one result among all waiters, so
  joining it would collapse those attempts to a single address.
- doUpgradeAsClient selects the security and muxer roles. upgradeOutbound
  and upgradeInbound are now one upgradeAs that derives every role from
  the direction, the way go-libp2p's upgrader does
  (isServer := dir == DirInbound). Peer B therefore calls connect() while
  running the responder side, without which both ends pick Yamux
  RoleClient and their odd stream ids collide.

The reversed connection is Inbound and its resource slot is reserved and
released as Inbound. go-libp2p reserves Outbound and reports Inbound;
here closeConnection releases by connDirection, so the two must agree.

Bound both the hole punch dial and the coordination exchange. Without a
bound, a simultaneous connect that fails to collide lands on the peer's
ordinary listener, leaving both ends running the responder side and
waiting on each other forever. go-libp2p bounds the same two things with
defaultDirectDialTimeout and StreamTimeout; the defaults match.

Add isPublicAddr, mirroring go-multiaddr's manet.IsPublicAddr: IPv4 by
exclusion, IPv6 by inclusion plus the NAT64 prefixes, DNS by special-use
domain. Nothing equivalent existed. isRelayedAddr moves next to it so the
connection pool can use it without depending on the NAT stack.

lookupConn now prefers a direct connection over a relayed one, keeping
the migration in one place as go-libp2p does in bestConnToPeer, and still
returns the relay when it is all there is, so a failed punch leaves it
usable. On success the relay is closed after a grace period, but only if
a direct connection is still up: it can die inside the window, and
dropping the relay too would strand the peer.

Closes #258.
@adust09
adust09 requested a review from seetadev August 24, 2026 03:57

@seetadev seetadev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@adust09 : Great work on this, Shouki. This is an important fix for making DCUtR perform the direct simultaneous connect rather than accidentally reusing the existing relay.

Appreciate the careful handling of force-direct dialing, deduplication, connection direction/upgrade roles, timeouts, and the direct-vs-relayed connection preference. The alignment with go-libp2p’s behavior and the additional testing around the failure/hang cases make this a solid improvement.

Great to see the relay fallback and grace-period handling preserved as well. Thanks for the thorough implementation and testing here. LGTM, happy to approve and merge.

@seetadev
seetadev merged commit 2f222e8 into main Aug 24, 2026
3 checks passed
@seetadev
seetadev deleted the feat/258-dcutr-production-integration branch August 24, 2026 05:28
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.

dcutr: production integration reuses the relay connection instead of dialing direct

2 participants